From d24675094d4418082b47938e11c68c97ee5358fa Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Fri, 27 Feb 2026 08:29:50 +0100 Subject: [PATCH] add isolated tab feature --- app/drift_schemas/tabs/drift_schema_v6.json | 457 ++++++ app/lib/core/design/app_colors.dart | 35 + app/lib/core/routing/routes.browser.dart | 2 +- app/lib/core/routing/routes.g.dart | 1 + .../about/presentation/screens/about.dart | 9 +- .../bangs/data/database/database.steps.dart | 5 +- .../presentation/widgets/bang_details.dart | 17 +- .../geckoview/domain/entities/states/tab.dart | 10 +- .../domain/entities/states/tab.g.dart | 14 +- .../features/geckoview/domain/providers.dart | 24 +- .../geckoview/domain/providers.g.dart | 2 +- .../geckoview/domain/providers/tab_state.dart | 26 +- .../domain/providers/tab_state.g.dart | 6 +- .../geckoview/domain/repositories/tab.dart | 245 ++- .../geckoview/domain/repositories/tab.g.dart | 2 +- .../services/proxy_settings_replication.dart | 109 ++ .../proxy_settings_replication.g.dart | 2 +- .../site_settings_badge_provider.dart | 3 +- .../site_settings_badge_provider.g.dart | 2 +- .../browser/presentation/screens/browser.dart | 28 + .../browser_modules/app_bar_title.dart | 12 +- .../browser_modules/bottom_app_bar.dart | 29 +- .../widgets/browser_modules/browser_view.dart | 24 +- .../browser_modules/navigation_drawer.dart | 13 +- .../presentation/widgets/sheets/view_tab.dart | 7 +- .../widgets/tab_creation_menu.dart | 11 + .../presentation/widgets/tab_menu.dart | 63 +- .../widgets/tab_view/tab_preview.dart | 123 +- .../widgets/tab_view/tab_tree_view.dart | 35 +- .../widgets/tab_view/tab_view_header.dart | 70 +- .../presentation/candidates/copy_image.dart | 3 +- .../candidates/open_image_new_tab.dart | 17 +- .../candidates/open_in_container.dart | 16 +- .../presentation/candidates/open_new_tab.dart | 17 +- .../presentation/candidates/save_file.dart | 3 +- .../presentation/candidates/save_image.dart | 3 +- .../presentation/candidates/share_image.dart | 3 +- .../history/presentation/screens/history.dart | 4 +- .../dialogs/open_shared_content.dart | 49 +- .../search/presentation/screens/search.dart | 49 +- .../widgets/animated_tab_type_switcher.dart | 9 + .../full_search_suggestions.dart | 8 +- .../search_modules/search_module_header.dart | 8 +- .../widgets/search_modules/tab_search.dart | 221 +-- .../tabs/data/database/daos/container.dart | 35 +- .../features/tabs/data/database/daos/tab.dart | 94 +- .../features/tabs/data/database/database.dart | 18 +- .../tabs/data/database/database.steps.dart | 145 +- .../tabs/data/database/definitions.drift | 47 +- .../tabs/data/database/definitions.drift.dart | 294 +++- .../tabs/data/entities/isolation_context.dart | 34 + .../features/tabs/data/entities/tab_mode.dart | 105 ++ .../tabs/data/models/tab_query_result.dart | 7 +- .../features/tabs/domain/providers.dart | 19 + .../features/tabs/domain/providers.g.dart | 66 + .../tabs/domain/repositories/tab.dart | 31 +- .../tabs/domain/repositories/tab.g.dart | 2 +- .../onboarding/presentation/onboarding.dart | 3 +- .../screens/tabs_behavior_settings.dart | 19 +- .../sync/domain/repositories/sync.dart | 3 +- .../sync/domain/repositories/sync.g.dart | 2 +- .../user/data/database/database.steps.dart | 5 +- .../user/data/models/general_settings.g.dart | 1 + .../presentation/screens/feed_article.dart | 25 +- .../controllers/website_title.dart | 6 +- .../controllers/website_title.g.dart | 2 +- app/lib/utils/ui_helper.dart | 31 + app/test/drift/bangs/generated/schema.dart | 5 +- app/test/drift/bangs/generated/schema_v1.dart | 5 +- app/test/drift/bangs/generated/schema_v2.dart | 5 +- app/test/drift/bangs/generated/schema_v3.dart | 5 +- app/test/drift/bangs/generated/schema_v4.dart | 5 +- app/test/drift/tabs/generated/schema.dart | 10 +- app/test/drift/tabs/generated/schema_v2.dart | 5 +- app/test/drift/tabs/generated/schema_v3.dart | 5 +- app/test/drift/tabs/generated/schema_v4.dart | 5 +- app/test/drift/tabs/generated/schema_v5.dart | 5 +- app/test/drift/tabs/generated/schema_v6.dart | 1356 +++++++++++++++++ app/test/drift/user/generated/schema.dart | 5 +- app/test/drift/user/generated/schema_v1.dart | 5 +- app/test/drift/user/generated/schema_v2.dart | 5 +- .../tabs/data/entities/tab_mode_test.dart | 21 + .../container_proxy/src/store/Store.ts | 32 +- .../container_proxy/test/unit/Store.test.ts | 27 + 84 files changed, 3783 insertions(+), 513 deletions(-) create mode 100644 app/drift_schemas/tabs/drift_schema_v6.json create mode 100644 app/lib/features/geckoview/features/tabs/data/entities/isolation_context.dart create mode 100644 app/lib/features/geckoview/features/tabs/data/entities/tab_mode.dart create mode 100644 app/test/drift/tabs/generated/schema_v6.dart create mode 100644 app/test/features/geckoview/features/tabs/data/entities/tab_mode_test.dart diff --git a/app/drift_schemas/tabs/drift_schema_v6.json b/app/drift_schemas/tabs/drift_schema_v6.json new file mode 100644 index 00000000..9849f268 --- /dev/null +++ b/app/drift_schemas/tabs/drift_schema_v6.json @@ -0,0 +1,457 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.3.0" + }, + "options": { + "store_date_time_values_as_text": false + }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "container", + "was_declared_in_moor": true, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "color", + "getter_name": "color", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const ColorConverter()", + "dart_type_name": "Color" + } + }, + { + "name": "metadata", + "getter_name": "metadata", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const ContainerMetadataConverter()", + "dart_type_name": "ContainerMetadata" + } + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 1, + "references": [ + 0 + ], + "type": "table", + "data": { + "name": "tab", + "was_declared_in_moor": true, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "source", + "getter_name": "source", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(TabSource.values)", + "dart_type_name": "TabSource" + } + }, + { + "name": "parent_id", + "getter_name": "parentId", + "moor_type": "string", + "nullable": true, + "customConstraints": "REFERENCES tab(id)ON DELETE SET NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "container_id", + "getter_name": "containerId", + "moor_type": "string", + "nullable": true, + "customConstraints": "REFERENCES container(id)ON DELETE CASCADE", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "container", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "order_key", + "getter_name": "orderKey", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const UriConverterNullable()", + "dart_type_name": "Uri?" + } + }, + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "tab_mode", + "getter_name": "tabMode", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 0", + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(TabModeDbValue.values)", + "dart_type_name": "TabModeDbValue" + } + }, + { + "name": "isolation_context_id", + "getter_name": "isolationContextId", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_probably_readerable", + "getter_name": "isProbablyReaderable", + "moor_type": "bool", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_markdown", + "getter_name": "extractedContentMarkdown", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_plain", + "getter_name": "extractedContentPlain", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_markdown", + "getter_name": "fullContentMarkdown", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_plain", + "getter_name": "fullContentPlain", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "timestamp", + "getter_name": "timestamp", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [ + "CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))" + ] + } + }, + { + "id": 2, + "references": [ + 1 + ], + "type": "table", + "data": { + "name": "tab_fts", + "was_declared_in_moor": true, + "columns": [ + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_plain", + "getter_name": "extractedContentPlain", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_plain", + "getter_name": "fullContentPlain", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": true, + "create_virtual_stmt": "CREATE VIRTUAL TABLE \"tab_fts\" USING fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize=\"trigram\")", + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 3, + "references": [ + 1 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1 + ], + "name": "tab_maintain_parent_chain_on_delete", + "sql": "CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN\n -- Update all children of the deleted row to point to its parent (grandparent)\n -- Only if the grandparent exists, otherwise set to NULL\n UPDATE tab\n SET parent_id = CASE\n WHEN OLD.parent_id IS NOT NULL AND\n EXISTS(SELECT 1 FROM tab WHERE id = OLD.parent_id)\n THEN OLD.parent_id\n ELSE NULL\n END\n WHERE parent_id = OLD.id;\nEND;" + } + }, + { + "id": 4, + "references": [ + 1, + 2 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 2 + ], + "name": "tab_after_insert", + "sql": "CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN\n INSERT INTO \n tab_fts(rowid, title, url, extracted_content_plain, full_content_plain) \n VALUES (new.rowid, new.title, new.url, new.extracted_content_plain, new.full_content_plain);\nEND;" + } + }, + { + "id": 5, + "references": [ + 1, + 2 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 2 + ], + "name": "tab_after_delete", + "sql": "CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN\n INSERT INTO \n tab_fts(tab_fts, rowid, title, url, extracted_content_plain, full_content_plain) \n VALUES('delete', old.rowid, old.title, old.url, old.extracted_content_plain, old.full_content_plain);\nEND;" + } + }, + { + "id": 6, + "references": [ + 1, + 2 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 2 + ], + "name": "tab_after_update", + "sql": "CREATE TRIGGER tab_after_update AFTER UPDATE ON tab BEGIN\n INSERT INTO \n tab_fts(tab_fts, rowid, title, url, extracted_content_plain, full_content_plain) \n VALUES('delete', old.rowid, old.title, old.url, old.extracted_content_plain, old.full_content_plain);\n INSERT INTO \n tab_fts(rowid, title, url, extracted_content_plain, full_content_plain) \n VALUES (new.rowid, new.title, new.url, new.extracted_content_plain, new.full_content_plain);\nEND;" + } + } + ], + "fixed_sql": [ + { + "name": "container", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"container\" (\"id\" TEXT PRIMARY KEY NOT NULL, \"name\" TEXT, \"color\" INTEGER NOT NULL, \"metadata\" TEXT);" + } + ] + }, + { + "name": "tab", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"tab\" (\"id\" TEXT PRIMARY KEY NOT NULL, \"source\" INTEGER NOT NULL, \"parent_id\" TEXT REFERENCES tab(id)ON DELETE SET NULL, \"container_id\" TEXT REFERENCES container(id)ON DELETE CASCADE, \"order_key\" TEXT NOT NULL, \"url\" TEXT, \"title\" TEXT, \"tab_mode\" INTEGER NOT NULL DEFAULT 0, \"isolation_context_id\" TEXT, \"is_probably_readerable\" INTEGER, \"extracted_content_markdown\" TEXT, \"extracted_content_plain\" TEXT, \"full_content_markdown\" TEXT, \"full_content_plain\" TEXT, \"timestamp\" INTEGER NOT NULL, CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL)));" + } + ] + }, + { + "name": "tab_fts", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE VIRTUAL TABLE IF NOT EXISTS \"tab_fts\" USING fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize=\"trigram\");" + } + ] + }, + { + "name": "tab_maintain_parent_chain_on_delete", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END" + } + ] + }, + { + "name": "tab_after_insert", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts (\"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES (new.\"rowid\", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END" + } + ] + }, + { + "name": "tab_after_delete", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, \"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES ('delete', old.\"rowid\", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END" + } + ] + }, + { + "name": "tab_after_update", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_after_update AFTER UPDATE ON tab BEGIN INSERT INTO tab_fts (tab_fts, \"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES ('delete', old.\"rowid\", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts (\"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES (new.\"rowid\", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END" + } + ] + } + ] +} \ No newline at end of file diff --git a/app/lib/core/design/app_colors.dart b/app/lib/core/design/app_colors.dart index d3d79e72..e36b3926 100644 --- a/app/lib/core/design/app_colors.dart +++ b/app/lib/core/design/app_colors.dart @@ -27,6 +27,10 @@ class AppColors extends ThemeExtension { this.privateTabBackground = const Color(0xFF25003E), this.privateTabForeground = const Color(0xFFFFFFFF), this.privateSelectionOverlay = const Color(0x648000D7), + this.isolatedTabTeal = const Color(0xFF00897B), + this.isolatedTabBackground = const Color(0xFF003D36), + this.isolatedTabForeground = const Color(0xFFFFFFFF), + this.isolatedSelectionOverlay = const Color(0x6400897B), this.torPurple = const Color(0xFF7D4698), this.torActiveGreen = const Color(0xFF68B030), this.torBackgroundGrey = const Color(0xFF333A41), @@ -38,6 +42,10 @@ class AppColors extends ThemeExtension { final Color privateTabBackground; final Color privateTabForeground; final Color privateSelectionOverlay; + final Color isolatedTabTeal; + final Color isolatedTabBackground; + final Color isolatedTabForeground; + final Color isolatedSelectionOverlay; final Color torPurple; final Color torActiveGreen; final Color torBackgroundGrey; @@ -53,6 +61,10 @@ class AppColors extends ThemeExtension { Color? privateTabBackground, Color? privateTabForeground, Color? privateSelectionOverlay, + Color? isolatedTabTeal, + Color? isolatedTabBackground, + Color? isolatedTabForeground, + Color? isolatedSelectionOverlay, Color? torPurple, Color? torActiveGreen, Color? torBackgroundGrey, @@ -65,6 +77,13 @@ class AppColors extends ThemeExtension { privateTabForeground: privateTabForeground ?? this.privateTabForeground, privateSelectionOverlay: privateSelectionOverlay ?? this.privateSelectionOverlay, + isolatedTabTeal: isolatedTabTeal ?? this.isolatedTabTeal, + isolatedTabBackground: + isolatedTabBackground ?? this.isolatedTabBackground, + isolatedTabForeground: + isolatedTabForeground ?? this.isolatedTabForeground, + isolatedSelectionOverlay: + isolatedSelectionOverlay ?? this.isolatedSelectionOverlay, torPurple: torPurple ?? this.torPurple, torActiveGreen: torActiveGreen ?? this.torActiveGreen, torBackgroundGrey: torBackgroundGrey ?? this.torBackgroundGrey, @@ -100,6 +119,22 @@ class AppColors extends ThemeExtension { other.privateSelectionOverlay, t, )!, + isolatedTabTeal: Color.lerp(isolatedTabTeal, other.isolatedTabTeal, t)!, + isolatedTabBackground: Color.lerp( + isolatedTabBackground, + other.isolatedTabBackground, + t, + )!, + isolatedTabForeground: Color.lerp( + isolatedTabForeground, + other.isolatedTabForeground, + t, + )!, + isolatedSelectionOverlay: Color.lerp( + isolatedSelectionOverlay, + other.isolatedSelectionOverlay, + t, + )!, torPurple: Color.lerp(torPurple, other.torPurple, t)!, torActiveGreen: Color.lerp(torActiveGreen, other.torActiveGreen, t)!, torBackgroundGrey: Color.lerp( diff --git a/app/lib/core/routing/routes.browser.dart b/app/lib/core/routing/routes.browser.dart index a19f3f06..7d0cdb1f 100644 --- a/app/lib/core/routing/routes.browser.dart +++ b/app/lib/core/routing/routes.browser.dart @@ -79,7 +79,7 @@ class BrowserRoute extends GoRouteData with $BrowserRoute { } } -enum TabType { regular, private, child } +enum TabType { regular, private, child, isolated } class SearchRoute extends GoRouteData with $SearchRoute { static const String emptySearchText = ' '; diff --git a/app/lib/core/routing/routes.g.dart b/app/lib/core/routing/routes.g.dart index b17c66c4..13067e61 100644 --- a/app/lib/core/routing/routes.g.dart +++ b/app/lib/core/routing/routes.g.dart @@ -664,6 +664,7 @@ const _$TabTypeEnumMap = { TabType.regular: 'regular', TabType.private: 'private', TabType.child: 'child', + TabType.isolated: 'isolated', }; mixin $TabViewRoute on GoRouteData { diff --git a/app/lib/features/about/presentation/screens/about.dart b/app/lib/features/about/presentation/screens/about.dart index 0ace8cc1..78031e40 100644 --- a/app/lib/features/about/presentation/screens/about.dart +++ b/app/lib/features/about/presentation/screens/about.dart @@ -24,6 +24,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/about/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; class AboutDialogScreen extends HookConsumerWidget { const AboutDialogScreen({super.key}); @@ -67,7 +68,7 @@ class AboutDialogScreen extends HookConsumerWidget { .read(tabRepositoryProvider.notifier) .addTab( url: Uri.https('feedback.weblibre.eu'), - private: false, + tabMode: TabMode.regular, selectTab: true, ); @@ -85,7 +86,7 @@ class AboutDialogScreen extends HookConsumerWidget { .read(tabRepositoryProvider.notifier) .addTab( url: Uri.https('github.com').replace(path: 'FaFre/WebLibre'), - private: false, + tabMode: TabMode.regular, selectTab: true, ); @@ -104,7 +105,7 @@ class AboutDialogScreen extends HookConsumerWidget { .read(tabRepositoryProvider.notifier) .addTab( url: Uri.https('docs.weblibre.eu'), - private: false, + tabMode: TabMode.regular, selectTab: true, ); @@ -123,7 +124,7 @@ class AboutDialogScreen extends HookConsumerWidget { .read(tabRepositoryProvider.notifier) .addTab( url: Uri.https('github.com').replace(path: 'FaFre/WebLibre'), - private: false, + tabMode: TabMode.regular, selectTab: true, ); diff --git a/app/lib/features/bangs/data/database/database.steps.dart b/app/lib/features/bangs/data/database/database.steps.dart index 56d7267b..f213c6be 100644 --- a/app/lib/features/bangs/data/database/database.steps.dart +++ b/app/lib/features/bangs/data/database/database.steps.dart @@ -1,9 +1,10 @@ // dart format width=80 import 'package:drift/internal/versioned_schema.dart' as i0; import 'package:drift/drift.dart' as i1; -import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import +import 'package:drift/drift.dart'; // GENERATED BY drift_dev, DO NOT MODIFY. -// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// final class Schema2 extends i0.VersionedSchema { Schema2({required super.database}) : super(version: 2); @override diff --git a/app/lib/features/bangs/presentation/widgets/bang_details.dart b/app/lib/features/bangs/presentation/widgets/bang_details.dart index 8891fb7c..441bf07a 100644 --- a/app/lib/features/bangs/presentation/widgets/bang_details.dart +++ b/app/lib/features/bangs/presentation/widgets/bang_details.dart @@ -23,6 +23,7 @@ import 'package:nullability/nullability.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/bangs/data/models/bang_data.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; @@ -88,19 +89,15 @@ class BangDetails extends HookConsumerWidget { ), onPressed: () async { final url = Uri.parse(bangData.getDefaultUrl().origin); - final isPrivate = - ref - .read(generalSettingsWithDefaultsProvider) - .defaultCreateTabType == - TabType.private; + final tabMode = TabMode.fromTabType( + ref + .read(generalSettingsWithDefaultsProvider) + .defaultCreateTabType, + ); await ref .read(tabRepositoryProvider.notifier) - .addTab( - url: url, - private: isPrivate, - selectTab: true, - ); + .addTab(url: url, tabMode: tabMode, selectTab: true); if (context.mounted) { const BrowserRoute().go(context); diff --git a/app/lib/features/geckoview/domain/entities/states/tab.dart b/app/lib/features/geckoview/domain/entities/states/tab.dart index fb498d90..0817d3dc 100644 --- a/app/lib/features/geckoview/domain/entities/states/tab.dart +++ b/app/lib/features/geckoview/domain/entities/states/tab.dart @@ -27,6 +27,7 @@ import 'package:weblibre/features/geckoview/domain/entities/states/find_result.d import 'package:weblibre/features/geckoview/domain/entities/states/history.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/security.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; part 'tab.g.dart'; @@ -61,7 +62,9 @@ class TabState extends WebPageInfo { final int progress; - final bool isPrivate; + final TabMode tabMode; + String? get isolationContextId => tabMode.isolationContextId; + final bool isFullScreen; final bool isLoading; final bool showToolbarAsExpanded; @@ -82,7 +85,7 @@ class TabState extends WebPageInfo { required this.icon, required this.thumbnail, required this.progress, - required this.isPrivate, + this.tabMode = TabMode.regular, required this.isFullScreen, required this.isLoading, required this.showToolbarAsExpanded, @@ -101,7 +104,6 @@ class TabState extends WebPageInfo { icon: null, thumbnail: null, progress: 0, - isPrivate: false, isFullScreen: false, isLoading: false, showToolbarAsExpanded: false, @@ -120,7 +122,7 @@ class TabState extends WebPageInfo { icon, thumbnail, progress, - isPrivate, + tabMode, isFullScreen, isLoading, showToolbarAsExpanded, diff --git a/app/lib/features/geckoview/domain/entities/states/tab.g.dart b/app/lib/features/geckoview/domain/entities/states/tab.g.dart index 11669ffa..49eac367 100644 --- a/app/lib/features/geckoview/domain/entities/states/tab.g.dart +++ b/app/lib/features/geckoview/domain/entities/states/tab.g.dart @@ -21,7 +21,7 @@ abstract class _$TabStateCWProxy { TabState progress(int progress); - TabState isPrivate(bool isPrivate); + TabState tabMode(TabMode tabMode); TabState isFullScreen(bool isFullScreen); @@ -52,7 +52,7 @@ abstract class _$TabStateCWProxy { EquatableImage? icon, EquatableImage? thumbnail, int progress, - bool isPrivate, + TabMode tabMode, bool isFullScreen, bool isLoading, bool showToolbarAsExpanded, @@ -92,7 +92,7 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy { TabState progress(int progress) => call(progress: progress); @override - TabState isPrivate(bool isPrivate) => call(isPrivate: isPrivate); + TabState tabMode(TabMode tabMode) => call(tabMode: tabMode); @override TabState isFullScreen(bool isFullScreen) => call(isFullScreen: isFullScreen); @@ -136,7 +136,7 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy { Object? icon = const $CopyWithPlaceholder(), Object? thumbnail = const $CopyWithPlaceholder(), Object? progress = const $CopyWithPlaceholder(), - Object? isPrivate = const $CopyWithPlaceholder(), + Object? tabMode = const $CopyWithPlaceholder(), Object? isFullScreen = const $CopyWithPlaceholder(), Object? isLoading = const $CopyWithPlaceholder(), Object? showToolbarAsExpanded = const $CopyWithPlaceholder(), @@ -175,10 +175,10 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy { ? _value.progress // ignore: cast_nullable_to_non_nullable : progress as int, - isPrivate: isPrivate == const $CopyWithPlaceholder() || isPrivate == null - ? _value.isPrivate + tabMode: tabMode == const $CopyWithPlaceholder() || tabMode == null + ? _value.tabMode // ignore: cast_nullable_to_non_nullable - : isPrivate as bool, + : tabMode as TabMode, isFullScreen: isFullScreen == const $CopyWithPlaceholder() || isFullScreen == null ? _value.isFullScreen diff --git a/app/lib/features/geckoview/domain/providers.dart b/app/lib/features/geckoview/domain/providers.dart index 078fed9c..9b377529 100644 --- a/app/lib/features/geckoview/domain/providers.dart +++ b/app/lib/features/geckoview/domain/providers.dart @@ -20,7 +20,6 @@ import 'dart:async'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; -import 'package:nullability/nullability.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -32,6 +31,7 @@ import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/domain/repositories/find_in_page.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; part 'providers.g.dart'; @@ -51,13 +51,10 @@ GeckoSelectionActionService selectionActionService(Ref ref) { tabStatesProvider, )[ref.read(selectedTabProvider)]; - final isPrivate = selectedTabState?.isPrivate.mapNotNull( - (isCurrentPrivate) => - isCurrentPrivate ? TabType.private : TabType.regular, - ); + final selectedTabType = selectedTabState?.tabMode.toTabType(); final route = SearchRoute( - tabType: isPrivate ?? settings.defaultCreateTabType, + tabType: selectedTabType ?? settings.defaultCreateTabType, searchText: text, ); @@ -76,19 +73,20 @@ GeckoSelectionActionService selectionActionService(Ref ref) { tabStatesProvider, )[ref.read(selectedTabProvider)]; - final isPrivate = - currentTab?.isPrivate ?? - ref - .read(generalSettingsWithDefaultsProvider) - .defaultCreateTabType == - TabType.private; + final tabMode = + currentTab?.tabMode ?? + TabMode.fromTabType( + ref + .read(generalSettingsWithDefaultsProvider) + .defaultCreateTabType, + ); await ref .read(tabRepositoryProvider.notifier) .addTab( url: defaultSearchBang.getTemplateUrl(text), parentId: currentTab?.id, - private: isPrivate, + tabMode: tabMode, selectTab: true, ); } else { diff --git a/app/lib/features/geckoview/domain/providers.g.dart b/app/lib/features/geckoview/domain/providers.g.dart index 2d842dea..3349c874 100644 --- a/app/lib/features/geckoview/domain/providers.g.dart +++ b/app/lib/features/geckoview/domain/providers.g.dart @@ -55,7 +55,7 @@ final class SelectionActionServiceProvider } String _$selectionActionServiceHash() => - r'75fa0fa770e74e49554d4098102fde9f9e531c4f'; + r'75701bb0be3302b461d296a7b0b28e3a30a9d346'; @ProviderFor(eventService) final eventServiceProvider = EventServiceProvider._(); diff --git a/app/lib/features/geckoview/domain/providers/tab_state.dart b/app/lib/features/geckoview/domain/providers/tab_state.dart index 1181e0c2..07495c9e 100644 --- a/app/lib/features/geckoview/domain/providers/tab_state.dart +++ b/app/lib/features/geckoview/domain/providers/tab_state.dart @@ -34,6 +34,8 @@ import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/domain/repositories/find_in_page.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolation_context.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; @@ -78,13 +80,23 @@ class TabStates extends _$TabStates { resolvedTitle = contentState.title; } + // Infer tabMode from context ID if not already set and context is isolated + final inferredTabMode = switch (current.tabMode) { + IsolatedTabMode() => current.tabMode, + _ when isIsolatedContextId(contentState.contextId) => TabMode.isolated( + contentState.contextId!, + ), + _ when contentState.isPrivate => TabMode.private, + _ => TabMode.regular, + }; + final newState = current.copyWith( parentId: contentState.parentId, contextId: contentState.contextId, url: url, title: resolvedTitle, progress: contentState.progress, - isPrivate: contentState.isPrivate, + tabMode: inferredTabMode, isFullScreen: contentState.isFullScreen, isLoading: contentState.isLoading, showToolbarAsExpanded: contentState.showToolbarAsExpanded, @@ -373,7 +385,9 @@ Future isTabTunneled(Ref ref, String? tabId) async { final torSettings = ref.watch(torSettingsWithDefaultsProvider); if (tabState != null) { - if (tabState.isPrivate) { + // Isolated tabs follow the same proxy rules as regular tabs + // (container-based routing via proxy aliasing) + if (tabState.tabMode is PrivateTabMode) { return torSettings.proxyPrivateTabsTor; } else { switch (torSettings.proxyRegularTabsMode) { @@ -402,13 +416,9 @@ TabState? selectedTabState(Ref ref) { @Riverpod() TabType? selectedTabType(Ref ref) { - final isPrivate = ref.watch( - selectedTabStateProvider.select((value) => value?.isPrivate), - ); + final selectedState = ref.watch(selectedTabStateProvider); - return isPrivate.mapNotNull( - (isCurrentPrivate) => isCurrentPrivate ? TabType.private : TabType.regular, - ); + return selectedState?.tabMode.toTabType(); } @Riverpod() diff --git a/app/lib/features/geckoview/domain/providers/tab_state.g.dart b/app/lib/features/geckoview/domain/providers/tab_state.g.dart index 065d40ff..4c5eff1e 100644 --- a/app/lib/features/geckoview/domain/providers/tab_state.g.dart +++ b/app/lib/features/geckoview/domain/providers/tab_state.g.dart @@ -41,7 +41,7 @@ final class TabStatesProvider } } -String _$tabStatesHash() => r'975de1242f42e2017754dc560125788b83ad8b72'; +String _$tabStatesHash() => r'0fd97193ff963006e1943436b25b025dd3885c65'; abstract class _$TabStates extends $Notifier> { Map build(); @@ -258,7 +258,7 @@ final class IsTabTunneledProvider } } -String _$isTabTunneledHash() => r'55a42ccb73d1921eed27d32f6da3a54680d4ac8c'; +String _$isTabTunneledHash() => r'7265f5e6f32056846d3df38f48476d10c3a70bac'; final class IsTabTunneledFamily extends $Family with $FunctionalFamilyOverride, String?> { @@ -358,7 +358,7 @@ final class SelectedTabTypeProvider } } -String _$selectedTabTypeHash() => r'53093fc6db8becde7500163661e087fb0a2eb955'; +String _$selectedTabTypeHash() => r'fe14b5c9f81d8f5859ae9d758e440c42908e8a54'; @ProviderFor(selectedTabContainerId) final selectedTabContainerIdProvider = SelectedTabContainerIdProvider._(); diff --git a/app/lib/features/geckoview/domain/repositories/tab.dart b/app/lib/features/geckoview/domain/repositories/tab.dart index 29260590..b21ab2e7 100644 --- a/app/lib/features/geckoview/domain/repositories/tab.dart +++ b/app/lib/features/geckoview/domain/repositories/tab.dart @@ -24,6 +24,7 @@ import 'package:drift/drift.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:nullability/nullability.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:synchronized/synchronized.dart'; import 'package:weblibre/core/logger.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; @@ -31,12 +32,16 @@ import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolation_context.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_source.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; +import 'package:weblibre/features/tor/domain/repositories/tor_proxy.dart'; import 'package:weblibre/utils/debouncer.dart'; part 'tab.g.dart'; @@ -46,6 +51,8 @@ class TabRepository extends _$TabRepository { final _tabsService = GeckoTabService(); final _tabFromIntent = {}; + final _closeLock = Lock(); + final _pendingIsolationCleanup = {}; bool hasLaunchedFromIntent(String? tabId) { if (tabId == null) { @@ -79,13 +86,13 @@ class TabRepository extends _$TabRepository { } Future addTab({ + required TabMode tabMode, Uri? url, required bool selectTab, bool startLoading = true, String? parentId, LoadUrlFlags flags = LoadUrlFlags.NONE, Source source = Internal.newTab, - required bool private, HistoryMetadataKey? historyMetadata, Map? additionalHeaders, TabContainerSelection containerSelection = @@ -101,10 +108,20 @@ class TabRepository extends _$TabRepository { SpecificContainerTabSelection(:final container) => container, }; - final validatedParentId = await _resolveParentIdForContext( - parentId: parentId, - targetContextId: assignedContainer?.metadata.contextualIdentity, - ); + // For isolated tabs, skip parent context validation since + // isolated tabs use their own immutable context ID. + final validatedParentId = tabMode is IsolatedTabMode + ? parentId + : await _resolveParentIdForContext( + parentId: parentId, + targetContextId: assignedContainer?.metadata.contextualIdentity, + ); + + final effectiveIsolationContextId = tabMode.isolationContextId; + + final effectiveContextId = tabMode is IsolatedTabMode + ? effectiveIsolationContextId + : assignedContainer?.metadata.contextualIdentity; final newTabId = await tabDao.upsertTabTransactional( () { @@ -114,17 +131,17 @@ class TabRepository extends _$TabRepository { startLoading: startLoading, parentId: validatedParentId, flags: flags, - contextId: assignedContainer?.metadata.contextualIdentity, + contextId: effectiveContextId, source: source, - private: private, + private: tabMode is PrivateTabMode, historyMetadata: historyMetadata, additionalHeaders: additionalHeaders, ); }, parentId: Value(validatedParentId), containerId: Value(assignedContainer?.id), - isPrivate: Value(private), url: Value(url), + tabMode: Value(tabMode), ); if (launchedFromIntent) { @@ -188,8 +205,14 @@ class TabRepository extends _$TabRepository { parentId: Value(validatedParentId), source: TabSource.manual, containerId: Value(assignedContainer?.id), - isPrivate: Value(tab.private), url: Value(Uri.tryParse(tab.url)), + tabMode: Value( + isIsolatedContextId(tab.contextId) + ? TabMode.isolated(tab.contextId!) + : tab.private + ? TabMode.private + : TabMode.regular, + ), ); } @@ -204,19 +227,35 @@ class TabRepository extends _$TabRepository { }) async { final tabDao = ref.read(tabDatabaseProvider).tabDao; + final sourceTabMode = + await tabDao.getTabMode(selectTabId).getSingleOrNull() ?? + TabMode.regular; + + // Duplicating an isolated tab creates a new isolation group + final duplicateIsolationContextId = sourceTabMode is IsolatedTabMode + ? newIsolatedContextId() + : null; + + final duplicateTabMode = sourceTabMode is IsolatedTabMode + ? TabMode.isolated(duplicateIsolationContextId!) + : sourceTabMode; + + // Isolated tabs always use their isolation context ID + final effectiveContextId = sourceTabMode is IsolatedTabMode + ? duplicateIsolationContextId + : containerData?.metadata.contextualIdentity; + return await tabDao.upsertTabTransactional( () { return _tabsService.duplicateTab( selectTabId: selectTabId, - newContextId: containerData?.metadata.contextualIdentity, + newContextId: effectiveContextId, selectNewTab: selectTab, ); }, parentId: const Value.absent(), containerId: Value(containerData?.id), - isPrivate: Value( - await tabDao.getTabIsPrivate(selectTabId).getSingleOrNull(), - ), + tabMode: Value(duplicateTabMode), ); } @@ -402,27 +441,148 @@ class TabRepository extends _$TabRepository { } } - Future closeTab(String tabId) async { - if (ref.read(selectedTabProvider) == tabId) { - await _selectNextTab(tabId); - } + Future closeTab(String tabId) { + return _closeLock.synchronized(() async { + // Collect isolation context before close + final isolationContextId = ref + .read(tabStatesProvider)[tabId] + ?.isolationContextId; - return _tabsService.removeTab(tabId: tabId); + if (ref.read(selectedTabProvider) == tabId) { + await _selectNextTab(tabId); + } + + await _tabsService.removeTab(tabId: tabId); + + // Queue isolation cleanup — actual cleanup runs after syncTabs + // deletes the DB row, so the count check is accurate. + if (isolationContextId != null) { + _pendingIsolationCleanup.add(isolationContextId); + } + }); } - Future closeTabs(List tabIds) async { - final selectedTab = ref.read(selectedTabProvider); - if (selectedTab.mapNotNull(tabIds.contains) ?? false) { - await _selectNextTab(selectedTab!); + Future closeTabs(List tabIds) { + return _closeLock.synchronized(() async { + // Collect isolation contexts from tabs being closed + for (final tabId in tabIds) { + final contextId = ref + .read(tabStatesProvider)[tabId] + ?.isolationContextId; + if (contextId != null) { + _pendingIsolationCleanup.add(contextId); + } + } + + final selectedTab = ref.read(selectedTabProvider); + if (selectedTab.mapNotNull(tabIds.contains) ?? false) { + await _selectNextTab(selectedTab!); + } + + await _tabsService.removeTabs(ids: tabIds); + }); + } + + /// Clears Gecko browsing data and removes proxy alias for an isolation + /// context if no more tabs share it. + Future _cleanupIsolationContextIfEmpty(String contextId) async { + final tabDao = ref.read(tabDatabaseProvider).tabDao; + + // Re-verify count after close (handles concurrent close races) + final remaining = await tabDao.tabsInIsolationGroup(contextId).getSingle(); + + if (remaining > 0) return; + + // Guard against debounced DB persistence: a sibling tab can already be + // active in-memory for this context before isolation_context_id is written. + final activeTabs = ref.read(tabListProvider).value; + final activeStates = ref.read(tabStatesProvider); + final hasActiveSibling = activeTabs.any((tabId) { + final state = activeStates[tabId]; + if (state == null) return false; + + return state.isolationContextId == contextId || + state.contextId == contextId; + }); + + if (hasActiveSibling) { + logger.i( + 'Skipping isolation cleanup for active context still in memory: $contextId', + ); + return; } - return _tabsService.removeTabs(ids: tabIds); + logger.i('Cleaning up isolation context: $contextId'); + + // Clear Gecko browsing data for this context + try { + await ref + .read(browserDataServiceProvider.notifier) + .clearDataForContext(contextId); + } catch (e, st) { + logger.e( + 'Failed to clear data for isolation context $contextId', + error: e, + stackTrace: st, + ); + } + + // Best-effort: remove proxy alias (no-op if never set) + try { + await ref + .read(torProxyRepositoryProvider.notifier) + .removeContainerProxy(contextId); + } catch (e, st) { + logger.e( + 'Failed to remove proxy for isolation context $contextId', + error: e, + stackTrace: st, + ); + } } Future undoClose() { return _tabsService.undo(); } + /// Cleans up isolation contexts from previous crashed sessions. + /// Called once after tab list stabilizes on startup. + // Future _cleanupOrphanedIsolationContexts() async { + // final tabDao = ref.read(tabDatabaseProvider).tabDao; + + // try { + // await _closeLock.synchronized(() async { + // if (!ref.mounted) return; + + // // Reconcile DB rows against the current engine tab snapshot, including + // // valid empty-tab sessions (retainTabIds can be empty here). + // final syncTabsResult = await tabDao.syncTabs( + // retainTabIds: ref.read(tabListProvider).value, + // ); + // _pendingIsolationCleanup.addAll( + // syncTabsResult.deletedIsolationContextIds, + // ); + + // if (_pendingIsolationCleanup.isNotEmpty) { + // final pending = Set.of(_pendingIsolationCleanup); + // _pendingIsolationCleanup.clear(); + + // for (final contextId in pending) { + // if (!ref.mounted) return; + // logger.i('Cleaning orphaned isolation context: $contextId'); + // await _cleanupIsolationContextIfEmpty(contextId); + // } + // } + // }); + // } catch (e, st) { + // logger.e( + // 'Error during orphan isolation context cleanup', + // error: e, + // stackTrace: st, + // ); + // } + // } + @override void build() { final eventSerivce = ref.watch(eventServiceProvider); @@ -438,7 +598,6 @@ class TabRepository extends _$TabRepository { parentId: const Value.absent(), source: TabSource.addedEvent, containerId: Value(containerId), - isPrivate: const Value.absent(), ); }, onError: (Object error, StackTrace stackTrace) { @@ -475,7 +634,7 @@ class TabRepository extends _$TabRepository { if (event.blocked || tabIsEmpty) { await addTab( url: uri, - private: tabState.isPrivate, + tabMode: tabState.tabMode, containerSelection: TabContainerSelection.specific( containerData, ), @@ -567,12 +726,42 @@ class TabRepository extends _$TabRepository { tabListProvider, (previous, next) async { //Only sync tabs if there has been a previous value or is not empty - final syncTabs = + final shouldSyncTabs = next.value.isNotEmpty || (previous?.value.isNotEmpty ?? false); - if (syncTabs) { - await db.tabDao.syncTabs(retainTabIds: next.value); + if (shouldSyncTabs) { + final syncTabsResult = await db.tabDao.syncTabs( + retainTabIds: next.value, + ); + // Capture isolation contexts from rows deleted by syncTabs + // (orphaned tabs from crashes, or tabs the engine dropped). + _pendingIsolationCleanup.addAll( + syncTabsResult.deletedIsolationContextIds, + ); } + + // Process pending isolation context cleanups after syncTabs + // has deleted the rows, so the count check is accurate. + if (_pendingIsolationCleanup.isNotEmpty) { + final pending = Set.of(_pendingIsolationCleanup); + _pendingIsolationCleanup.clear(); + for (final contextId in pending) { + if (!ref.mounted) break; + await _cleanupIsolationContextIfEmpty(contextId); + } + } + + // One-shot orphan cleanup after tab list stabilizes (5s debounce). + // Also runs for DB-only contexts whose rows were already deleted + // by syncTabs above (those are handled via _pendingIsolationCleanup). + // if (!orphanCleanupDone) { + // orphanCleanupTimer?.cancel(); + // orphanCleanupTimer = Timer(const Duration(seconds: 5), () async { + // if (orphanCleanupDone || !ref.mounted) return; + // orphanCleanupDone = true; + // await _cleanupOrphanedIsolationContexts(); + // }); + // } }, onError: (Object error, StackTrace stackTrace) { logger.e( diff --git a/app/lib/features/geckoview/domain/repositories/tab.g.dart b/app/lib/features/geckoview/domain/repositories/tab.g.dart index 03d07fee..5996d0fd 100644 --- a/app/lib/features/geckoview/domain/repositories/tab.g.dart +++ b/app/lib/features/geckoview/domain/repositories/tab.g.dart @@ -41,7 +41,7 @@ final class TabRepositoryProvider } } -String _$tabRepositoryHash() => r'd77e4e74bb7ee1b466172bca245b05c6bf03196c'; +String _$tabRepositoryHash() => r'a6953b50e4b9d0e7e297a058872b9ad1c7d55c1a'; abstract class _$TabRepository extends $Notifier { void build(); diff --git a/app/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart b/app/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart index 969e07a8..74b90932 100644 --- a/app/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart +++ b/app/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart @@ -21,8 +21,11 @@ import 'package:fast_equatable/fast_equatable.dart'; import 'package:nullability/nullability.dart'; import 'package:riverpod/riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:synchronized/synchronized.dart'; import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/tor/domain/repositories/tor_proxy.dart'; import 'package:weblibre/features/tor/domain/services/tor_proxy.dart'; import 'package:weblibre/features/user/data/models/tor_settings.dart'; @@ -32,6 +35,82 @@ part 'proxy_settings_replication.g.dart'; @Riverpod(keepAlive: true) class ProxySettingsReplication extends _$ProxySettingsReplication { + var _proxiedIsolationContexts = {}; + + final _recomputeLock = Lock(); + + Future _queueIsolatedProxyAliasesRecompute() async { + if (_recomputeLock.inLock) { + return; + } + + await _recomputeLock.synchronized(() async { + try { + await _recomputeIsolatedProxyAliases(); + } catch (error, stackTrace) { + logger.e( + 'Error recomputing isolated proxy aliases', + error: error, + stackTrace: stackTrace, + ); + } + }); + } + + Future _recomputeIsolatedProxyAliases() async { + final db = ref.read(tabDatabaseProvider); + + final contextContainerMap = >{}; + final pairs = await db.tabDao.isolatedContextContainerPairs().get(); + + for (final pair in pairs) { + final isolationContextId = pair.isolationContextId; + final containerId = pair.containerId; + + if (isolationContextId == null || containerId == null) continue; + + contextContainerMap + .putIfAbsent(isolationContextId, () => {}) + .add(containerId); + } + + final containers = await ref + .read(containerRepositoryProvider.notifier) + .getAllContainersWithCount(); + + // Build set of container IDs that have useProxy enabled. + final proxiedContainerIds = { + for (final c in containers) + if (c.metadata.useProxy) c.id, + }; + + // Compute which isolation contexts need proxy aliases. + // A context needs an alias if ANY of its associated containers + // has useProxy enabled. + final newProxied = { + for (final entry in contextContainerMap.entries) + if (entry.value.any(proxiedContainerIds.contains)) entry.key, + }; + + // Remove aliases that are no longer needed. + final toRemove = _proxiedIsolationContexts.difference(newProxied); + for (final contextId in toRemove) { + await ref + .read(torProxyRepositoryProvider.notifier) + .removeContainerProxy(contextId); + } + + // Add aliases that are newly needed. + final toAdd = newProxied.difference(_proxiedIsolationContexts); + for (final contextId in toAdd) { + await ref + .read(torProxyRepositoryProvider.notifier) + .addContainerProxy(contextId); + } + + _proxiedIsolationContexts = newProxied; + } + @override void build() { ref.listen( @@ -142,5 +221,35 @@ class ProxySettingsReplication extends _$ProxySettingsReplication { .setSiteAssignments(next.requireValue); } }); + + ref.listen( + fireImmediately: true, + watchIsolatedContextContainerMapProvider.select( + (value) => EquatableValue(value.value), + ), + (previous, next) => _queueIsolatedProxyAliasesRecompute(), + onError: (error, stackTrace) { + logger.e( + 'Error listening to isolated context proxy aliases', + error: error, + stackTrace: stackTrace, + ); + }, + ); + + ref.listen( + fireImmediately: true, + watchContainersWithCountProvider.select( + (value) => EquatableValue(value.value), + ), + (previous, next) => _queueIsolatedProxyAliasesRecompute(), + onError: (error, stackTrace) { + logger.e( + 'Error listening to container proxy changes for isolated aliases', + error: error, + stackTrace: stackTrace, + ); + }, + ); } } diff --git a/app/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.g.dart b/app/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.g.dart index 6f5bc991..2cd4cedc 100644 --- a/app/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.g.dart +++ b/app/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.g.dart @@ -42,7 +42,7 @@ final class ProxySettingsReplicationProvider } String _$proxySettingsReplicationHash() => - r'e4c5e35b9aab2aae60e3f09a99472f98a7beb69e'; + r'79eca77aa476ed46cfe342e52926ea1cf956dc70'; abstract class _$ProxySettingsReplication extends $Notifier { void build(); diff --git a/app/lib/features/geckoview/features/browser/presentation/providers/site_settings_badge_provider.dart b/app/lib/features/geckoview/features/browser/presentation/providers/site_settings_badge_provider.dart index 27a92171..d5302f9a 100644 --- a/app/lib/features/geckoview/features/browser/presentation/providers/site_settings_badge_provider.dart +++ b/app/lib/features/geckoview/features/browser/presentation/providers/site_settings_badge_provider.dart @@ -23,6 +23,7 @@ import 'package:weblibre/extensions/uri.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/repositories/site_permissions.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/tracking_protection_provider.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; part 'site_settings_badge_provider.g.dart'; @@ -48,7 +49,7 @@ Future showSiteSettingsBadge(Ref ref) async { final permissions = await ref.watch( sitePermissionsRepositoryProvider( origin: tabState.url.origin, - isPrivate: tabState.isPrivate, + isPrivate: tabState.tabMode is PrivateTabMode, ).future, ); diff --git a/app/lib/features/geckoview/features/browser/presentation/providers/site_settings_badge_provider.g.dart b/app/lib/features/geckoview/features/browser/presentation/providers/site_settings_badge_provider.g.dart index e27d6569..3bd53bfe 100644 --- a/app/lib/features/geckoview/features/browser/presentation/providers/site_settings_badge_provider.g.dart +++ b/app/lib/features/geckoview/features/browser/presentation/providers/site_settings_badge_provider.g.dart @@ -48,4 +48,4 @@ final class ShowSiteSettingsBadgeProvider } String _$showSiteSettingsBadgeHash() => - r'd1499809901363705d23b17aab375fd414080544'; + r'f44282c62556c39d439c499519a2a9861082cfcb'; diff --git a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart index 9ee666c2..a286649e 100644 --- a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -798,6 +798,22 @@ class _Browser extends HookConsumerWidget { final overlayBuilder = ref.watch(overlayControllerProvider); + Future confirmIsolatedTabCloseIfNeeded(String tabId) async { + final allStates = ref.read(tabStatesProvider); + final tabState = allStates[tabId]; + final contextId = tabState?.isolationContextId; + + if (contextId == null) return true; + + final groupCount = allStates.values + .where((state) => state.isolationContextId == contextId) + .length; + + if (groupCount > 1 || !context.mounted) return groupCount > 1; + + return ui_helper.confirmIsolatedTabClose(context); + } + return DragTarget( onMove: (details) { ref @@ -809,6 +825,10 @@ class _Browser extends HookConsumerWidget { }, onAcceptWithDetails: (details) async { ref.read(willAcceptDropProvider.notifier).clear(); + if (!await confirmIsolatedTabCloseIfNeeded(details.data.tabId)) { + return; + } + await ref .read(tabRepositoryProvider.notifier) .closeTab(details.data.tabId); @@ -916,6 +936,10 @@ class _Browser extends HookConsumerWidget { } if (tabState != null) { + if (!await confirmIsolatedTabCloseIfNeeded(tabState.id)) { + return true; + } + await ref .read(tabRepositoryProvider.notifier) .closeTab(tabState.id); @@ -933,6 +957,10 @@ class _Browser extends HookConsumerWidget { lastBackButtonPress.value = null; if (tabState != null && tabCount > 1) { + if (!await confirmIsolatedTabCloseIfNeeded(tabState.id)) { + return true; + } + await ref .read(tabRepositoryProvider.notifier) .closeTab(tabState.id); diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart index 797c63c4..f7e61231 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart @@ -32,6 +32,7 @@ import 'package:weblibre/features/geckoview/features/browser/domain/entities/she import 'package:weblibre/features/geckoview/features/browser/presentation/providers/site_settings_badge_provider.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_icon.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/toolbar_button.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; class AppBarTitle extends HookConsumerWidget { @@ -117,7 +118,7 @@ class AppBarTitle extends HookConsumerWidget { searchText: searchText.isEmpty ? SearchRoute.emptySearchText : searchText, - tabType: tabState.isPrivate ? TabType.private : TabType.regular, + tabType: tabState.tabMode.toTabType(), ).push(context); }, child: Column( @@ -158,13 +159,20 @@ class AppBarTitle extends HookConsumerWidget { ], icon, const SizedBox(width: 4), - if (tabState.isPrivate) ...[ + if (tabState.tabMode is PrivateTabMode) ...[ Icon( MdiIcons.dominoMask, color: appColors.privateTabPurple, size: 14, ), const SizedBox(width: 4), + ] else if (tabState.tabMode is IsolatedTabMode) ...[ + Icon( + MdiIcons.shieldLock, + color: appColors.isolatedTabTeal, + size: 14, + ), + const SizedBox(width: 4), ], Expanded( child: UriBreadcrumb( diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart index e96e09bf..783ff81c 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart @@ -50,6 +50,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart'; import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart'; import 'package:weblibre/features/geckoview/features/readerview/presentation/widgets/reader_button.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; @@ -405,7 +406,7 @@ class BrowserTabBar extends HookConsumerWidget { typedef _QuickTabItem = ({ Color? color, String id, - bool isPrivate, + TabMode tabMode, bool isHistory, String title, Uri url, @@ -522,7 +523,10 @@ class QuickTabSwitcher extends HookConsumerWidget { scrollController: chipScrollController, itemId: (item) => item.id, labelPadding: (item) => - (!showTitles && !item.isHistory && !item.isPrivate) + (!showTitles && + !item.isHistory && + item.tabMode is! PrivateTabMode && + item.tabMode is! IsolatedTabMode) ? EdgeInsets.zero : null, itemLabel: (item) { @@ -534,7 +538,16 @@ class QuickTabSwitcher extends HookConsumerWidget { constraints: const BoxConstraints(maxWidth: 64), child: Text(item.title), ), - if (item.isPrivate) + if (item.tabMode is IsolatedTabMode) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Icon( + MdiIcons.shieldLock, + color: appColors.isolatedTabTeal, + size: 20, + ), + ) + else if (item.tabMode is PrivateTabMode) Padding( padding: const EdgeInsets.only(left: 8.0), child: Icon( @@ -567,7 +580,11 @@ class QuickTabSwitcher extends HookConsumerWidget { if (item.isHistory) { await ref .read(tabRepositoryProvider.notifier) - .addTab(url: item.url, private: false, selectTab: true); + .addTab( + url: item.url, + tabMode: TabMode.regular, + selectTab: true, + ); } else { await ref.read(tabRepositoryProvider.notifier).selectTab(item.id); } @@ -606,7 +623,7 @@ class QuickTabSwitcher extends HookConsumerWidget { (state) => ( id: state.$1.id, title: state.$1.titleOrAuthority, - isPrivate: state.$1.isPrivate, + tabMode: state.$1.tabMode, isHistory: false, url: state.$1.url, color: state.$2?.color, @@ -620,7 +637,7 @@ class QuickTabSwitcher extends HookConsumerWidget { return ( id: state.url, title: state.title ?? url.authority, - isPrivate: false, + tabMode: TabMode.regular, isHistory: true, url: url, color: null, diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart index aa4b8112..f4d8c812 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart @@ -49,6 +49,7 @@ import 'package:weblibre/features/geckoview/features/browser/domain/services/pro import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart'; import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart'; import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/share_intent/domain/entities/shared_content.dart'; @@ -202,9 +203,11 @@ class _BrowserViewState extends ConsumerState .read(tabRepositoryProvider.notifier) .addTab( url: sharedContent.url, - private: + tabMode: settings.tabIntentOpenSetting == - TabIntentOpenSetting.private, + TabIntentOpenSetting.private + ? TabMode.private + : TabMode.regular, launchedFromIntent: true, selectTab: true, ); @@ -217,9 +220,11 @@ class _BrowserViewState extends ConsumerState .read(tabRepositoryProvider.notifier) .addTab( url: bang?.getTemplateUrl(sharedContent.text), - private: + tabMode: settings.tabIntentOpenSetting == - TabIntentOpenSetting.private, + TabIntentOpenSetting.private + ? TabMode.private + : TabMode.regular, launchedFromIntent: true, selectTab: true, ); @@ -327,6 +332,13 @@ class _BrowserViewState extends ConsumerState final router = await ref.read(routerProvider.future); const route = SearchRoute(tabType: TabType.private); + await router.push(route.location); + } else if (type == 'new_isolated_tab') { + lastAction = DateTime.now(); + + final router = await ref.read(routerProvider.future); + const route = SearchRoute(tabType: TabType.isolated); + await router.push(route.location); } else { throw UnimplementedError( @@ -346,6 +358,10 @@ class _BrowserViewState extends ConsumerState type: 'new_private_tab', localizedTitle: 'New Private Tab', ), + const ShortcutItem( + type: 'new_isolated_tab', + localizedTitle: 'New Isolated Tab', + ), ]); initializationCompleter.complete(); diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart index 5858bece..d7731516 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart @@ -33,6 +33,7 @@ import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart'; import 'package:weblibre/features/sync/domain/repositories/sync.dart'; import 'package:weblibre/features/tor/domain/services/tor_proxy.dart'; @@ -318,17 +319,17 @@ class _ExtensionsSection extends HookConsumerWidget { dense: true, onTap: () async { Navigator.of(context).pop(); // Close drawer - final isPrivate = - ref - .read(generalSettingsWithDefaultsProvider) - .defaultCreateTabType == - TabType.private; + final tabMode = TabMode.fromTabType( + ref + .read(generalSettingsWithDefaultsProvider) + .defaultCreateTabType, + ); await ref .read(tabRepositoryProvider.notifier) .addTab( url: Uri.parse('https://addons.mozilla.org'), - private: isPrivate, + tabMode: tabMode, containerSelection: const TabContainerSelection.unassigned(), selectTab: true, diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart index a28d70de..e474e2ea 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart @@ -29,6 +29,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/clear_site_data_section.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/permissions_section.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/tracking_protection_section.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/presentation/widgets/website_title_tile.dart'; class ClampingScrollPhysicsWithoutImplicit extends ClampingScrollPhysics { @@ -121,9 +122,7 @@ class ViewTabSheetWidget extends HookConsumerWidget { searchText: searchText.isEmpty ? SearchRoute.emptySearchText : searchText, - tabType: initialTabState.isPrivate - ? TabType.private - : TabType.regular, + tabType: initialTabState.tabMode.toTabType(), ).push(context); }, child: WebsiteTitleTile(initialTabState), @@ -151,7 +150,7 @@ class ViewTabSheetWidget extends HookConsumerWidget { // Permissions Section PermissionsSection( origin: initialTabState.url.origin, - isPrivate: initialTabState.isPrivate, + isPrivate: initialTabState.tabMode is PrivateTabMode, ), const Divider(), // Clear Site Data Section diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_creation_menu.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_creation_menu.dart index 97883ce5..4f94fc83 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_creation_menu.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_creation_menu.dart @@ -20,6 +20,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/design/app_colors.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/presentation/icons/weblibre_icons.dart'; @@ -62,6 +63,16 @@ class TabCreationMenu extends HookConsumerWidget { leadingIcon: const Icon(WebLibreIcons.privateTab), child: const Text('Add Private Tab'), ), + MenuItemButton( + onPressed: () async { + await const SearchRoute(tabType: TabType.isolated).push(context); + }, + leadingIcon: Icon( + MdiIcons.shieldLock, + color: AppColors.of(context).isolatedTabTeal, + ), + child: const Text('Add Isolated Tab'), + ), if (createChildTabsOption) MenuItemButton( onPressed: () async { diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart index dfc7042b..135c618a 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart @@ -27,6 +27,7 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; +import 'package:weblibre/core/design/app_colors.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart'; import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; @@ -41,6 +42,7 @@ import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/pwa/presentation/widgets/pwa_install_button.dart'; import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart'; import 'package:weblibre/features/geckoview/features/readerview/presentation/widgets/reader_button.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/entities/container_selection_result.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; @@ -234,12 +236,12 @@ class TabMenu extends HookConsumerWidget { .read(tabDataRepositoryProvider.notifier) .getTabContainerData(selectedTabId); - final tabId = (tabState.isPrivate) + final tabId = (tabState.tabMode is! RegularTabMode) ? await ref .read(tabRepositoryProvider.notifier) .addTab( + tabMode: TabMode.regular, url: tabState.url, - private: false, containerSelection: containerData == null ? const TabContainerSelection.unassigned() : TabContainerSelection.specific( @@ -277,12 +279,12 @@ class TabMenu extends HookConsumerWidget { .read(tabDataRepositoryProvider.notifier) .getTabContainerData(selectedTabId); - final tabId = (!tabState.isPrivate) + final tabId = (tabState.tabMode is! PrivateTabMode) ? await ref .read(tabRepositoryProvider.notifier) .addTab( url: tabState.url, - private: true, + tabMode: TabMode.private, containerSelection: containerData == null ? const TabContainerSelection.unassigned() : TabContainerSelection.specific( @@ -302,6 +304,41 @@ class TabMenu extends HookConsumerWidget { //save reference before pop `ref` gets disposed final repo = ref.read(tabRepositoryProvider.notifier); + ui_helper.showTabSwitchMessage( + context, + onSwitch: () async { + await repo.selectTab(tabId); + }, + ); + } + }, + ), + MenuItemButton( + leadingIcon: Icon( + MdiIcons.shieldLock, + color: AppColors.of(context).isolatedTabTeal, + ), + child: const Text('Isolated'), + onPressed: () async { + final tabState = ref.read(tabStateProvider(selectedTabId))!; + final containerData = await ref + .read(tabDataRepositoryProvider.notifier) + .getTabContainerData(selectedTabId); + + final tabId = await ref + .read(tabRepositoryProvider.notifier) + .addTab( + url: tabState.url, + tabMode: TabMode.newIsolated(), + containerSelection: containerData == null + ? const TabContainerSelection.unassigned() + : TabContainerSelection.specific(containerData), + selectTab: false, + ); + + if (context.mounted) { + final repo = ref.read(tabRepositoryProvider.notifier); + ui_helper.showTabSwitchMessage( context, onSwitch: () async { @@ -540,6 +577,24 @@ class TabMenu extends HookConsumerWidget { if (enableCloseTab) MenuItemButton( onPressed: () async { + // Confirm before closing the last tab in an isolation group + final tabState = ref.read(tabStateProvider(selectedTabId)); + if (tabState != null && tabState.tabMode is IsolatedTabMode) { + final allStates = ref.read(tabStatesProvider); + final groupCount = allStates.values + .where( + (s) => + s.isolationContextId == tabState.isolationContextId, + ) + .length; + if (groupCount <= 1 && context.mounted) { + final confirmed = await ui_helper.confirmIsolatedTabClose( + context, + ); + if (!confirmed) return; + } + } + await ref .read(tabRepositoryProvider.notifier) .closeTab(selectedTabId); diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart index 36ffe269..b2358e22 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart @@ -31,6 +31,7 @@ import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_icon.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/domain/entities/find_in_page_state.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/presentation/hooks/menu_controller.dart'; @@ -39,14 +40,35 @@ import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; import 'package:weblibre/utils/ui_helper.dart' as ui_helper; +Future _confirmIsolatedTabCloseIfNeeded( + BuildContext context, + WidgetRef ref, + String tabId, +) async { + final allStates = ref.read(tabStatesProvider); + final tabState = allStates[tabId]; + final contextId = tabState?.isolationContextId; + + if (contextId == null) return true; + + final groupCount = allStates.values + .where((state) => state.isolationContextId == contextId) + .length; + + if (groupCount > 1) return true; + if (!context.mounted) return false; + + return ui_helper.confirmIsolatedTabClose(context); +} + class GridTabItemContainer extends StatelessWidget { final bool isActive; - final bool isPrivate; + final TabMode tabMode; final Widget? child; const GridTabItemContainer({ required this.isActive, - required this.isPrivate, + required this.tabMode, this.child, super.key, }); @@ -56,6 +78,12 @@ class GridTabItemContainer extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; final appColors = AppColors.of(context); + final bgColor = switch (tabMode) { + PrivateTabMode() => appColors.privateTabBackground, + IsolatedTabMode() => appColors.isolatedTabBackground, + RegularTabMode() => colorScheme.surfaceContainerHighest, + }; + return Container( decoration: BoxDecoration( border: Border.all( @@ -65,9 +93,7 @@ class GridTabItemContainer extends StatelessWidget { borderRadius: const BorderRadius.all(Radius.circular(16.0)), ), child: Material( - color: isPrivate - ? appColors.privateTabBackground - : colorScheme.surfaceContainerHighest, + color: bgColor, borderRadius: const BorderRadius.all(Radius.circular(14.0)), child: child, ), @@ -113,9 +139,15 @@ class GridTabPreview extends HookConsumerWidget { final extendedDeleteMenuController = useMenuController(); + final modeTextColor = switch (tabState.tabMode) { + PrivateTabMode() => appColors.privateTabForeground, + IsolatedTabMode() => appColors.isolatedTabForeground, + RegularTabMode() => null, + }; + return GridTabItemContainer( isActive: isActive, - isPrivate: tabState.isPrivate, + tabMode: tabState.tabMode, child: InkWell( borderRadius: const BorderRadius.all(Radius.circular(14.0)), onTap: onTap, @@ -132,8 +164,8 @@ class GridTabPreview extends HookConsumerWidget { overflow: TextOverflow.ellipsis, tabState.titleOrAuthority, maxLines: 2, - style: tabState.isPrivate - ? TextStyle(color: appColors.privateTabForeground) + style: modeTextColor != null + ? TextStyle(color: modeTextColor) : null, ), ), @@ -183,14 +215,13 @@ class GridTabPreview extends HookConsumerWidget { Expanded( child: Text( tabState.url.authority, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tabState.isPrivate - ? appColors.privateTabForeground - : null, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: modeTextColor), ), ), - if (tabState.isPrivate) ...[ + if (tabState.tabMode is PrivateTabMode || + tabState.tabMode is IsolatedTabMode) ...[ const SizedBox(width: 6.0), SizedBox( height: 16, @@ -201,8 +232,12 @@ class GridTabPreview extends HookConsumerWidget { Positioned( top: -4, child: Icon( - MdiIcons.dominoMask, - color: appColors.privateTabPurple, + tabState.tabMode is IsolatedTabMode + ? MdiIcons.shieldLock + : MdiIcons.dominoMask, + color: tabState.tabMode is IsolatedTabMode + ? appColors.isolatedTabTeal + : appColors.privateTabPurple, ), ), ], @@ -290,9 +325,25 @@ class ListTabPreview extends HookConsumerWidget { _ => TabIcon(tabState: tabState, iconSize: 32), }; + final listBgColor = switch (tabState.tabMode) { + PrivateTabMode() => appColors.privateTabBackground, + IsolatedTabMode() => appColors.isolatedTabBackground, + RegularTabMode() => null, + }; + final listTextColor = switch (tabState.tabMode) { + PrivateTabMode() => appColors.privateTabForeground, + IsolatedTabMode() => appColors.isolatedTabForeground, + RegularTabMode() => null, + }; + final (modeBadgeIcon, modeBadgeColor) = switch (tabState.tabMode) { + PrivateTabMode() => (MdiIcons.dominoMask, appColors.privateTabPurple), + IsolatedTabMode() => (MdiIcons.shieldLock, appColors.isolatedTabTeal), + RegularTabMode() => (null, null), + }; + return Container( decoration: BoxDecoration( - color: tabState.isPrivate ? appColors.privateTabBackground : null, + color: listBgColor, border: isActive ? Border.all(color: colorScheme.primary) : null, borderRadius: const BorderRadius.all(Radius.circular(4.0)), ), @@ -309,28 +360,22 @@ class ListTabPreview extends HookConsumerWidget { overflow: TextOverflow.ellipsis, tabState.titleOrAuthority, maxLines: 2, - style: tabState.isPrivate - ? TextStyle(color: appColors.privateTabForeground) + style: listTextColor != null + ? TextStyle(color: listTextColor) : null, ), subtitle: Row( children: [ - if (tabState.isPrivate) ...[ - Icon( - MdiIcons.dominoMask, - color: appColors.privateTabPurple, - size: 14, - ), + if (modeBadgeIcon != null) ...[ + Icon(modeBadgeIcon, color: modeBadgeColor, size: 14), const SizedBox(width: 4), ], Expanded( child: UriBreadcrumb( uri: tabState.url, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tabState.isPrivate - ? appColors.privateTabForeground - : null, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: listTextColor), ), ), ], @@ -463,6 +508,11 @@ class SingleGridTabPreview extends HookConsumerWidget { }, onHorizontalDragEnd: (details) async { if (draggedDistance.value >= deleteThreshold) { + if (!await _confirmIsolatedTabCloseIfNeeded(context, ref, tabId)) { + draggedDistance.value = 0.0; + return; + } + await ref.read(tabRepositoryProvider.notifier).closeTab(tabId); if (context.mounted) { @@ -525,6 +575,10 @@ class SingleGridTabPreview extends HookConsumerWidget { onDelete: () async { onBeforeDelete?.call(); + if (!await _confirmIsolatedTabCloseIfNeeded(context, ref, tabId)) { + return; + } + await ref.read(tabRepositoryProvider.notifier).closeTab(tabId); if (context.mounted) { @@ -579,6 +633,11 @@ class SingleListTabPreview extends HookConsumerWidget { }, onHorizontalDragEnd: (details) async { if (draggedDistance.value >= deleteThreshold) { + if (!await _confirmIsolatedTabCloseIfNeeded(context, ref, tabId)) { + draggedDistance.value = 0.0; + return; + } + await ref.read(tabRepositoryProvider.notifier).closeTab(tabId); if (context.mounted) { @@ -641,6 +700,10 @@ class SingleListTabPreview extends HookConsumerWidget { onDelete: () async { onBeforeDelete?.call(); + if (!await _confirmIsolatedTabCloseIfNeeded(context, ref, tabId)) { + return; + } + await ref.read(tabRepositoryProvider.notifier).closeTab(tabId); if (context.mounted) { diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart index b79c2f2b..ec47a943 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart @@ -71,11 +71,11 @@ class _TabTreePreview extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isTabPrivate = ref.watch( - tabStateProvider(entity.tabId).select((value) => value?.isPrivate), + final tabMode = ref.watch( + tabStateProvider(entity.tabId).select((value) => value?.tabMode), ); - if (isTabPrivate == null) { + if (tabMode == null) { return const SizedBox.shrink(); } @@ -89,7 +89,7 @@ class _TabTreePreview extends HookConsumerWidget { stackCount, GridTabItemContainer( isActive: entity.tabId == activeTabId, - isPrivate: isTabPrivate == true, + tabMode: tabMode, ), ), _addPadding( @@ -152,6 +152,33 @@ class _TabTreePreview extends HookConsumerWidget { .read(tabDataRepositoryProvider.notifier) .getTabDescendants(entity.rootId); + final allStates = ref.read(tabStatesProvider); + final isolatedContextInCloseSet = {}; + + for (final tabId in tabs.keys) { + final contextId = allStates[tabId]?.isolationContextId; + if (contextId == null) continue; + isolatedContextInCloseSet[contextId] = + (isolatedContextInCloseSet[contextId] ?? 0) + 1; + } + + final groupsToDelete = isolatedContextInCloseSet.entries.where(( + entry, + ) { + final totalInGroup = allStates.values + .where((state) => state.isolationContextId == entry.key) + .length; + return totalInGroup == entry.value; + }).length; + + if (groupsToDelete > 0 && context.mounted) { + final confirmed = await ui_helper.confirmIsolatedTabClose( + context, + groupCount: groupsToDelete, + ); + if (!confirmed) return; + } + await ref .read(tabRepositoryProvider.notifier) .closeTabs(tabs.keys.toList()); diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart index 19cc3912..74300921 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart @@ -25,10 +25,12 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/design/app_colors.dart'; import 'package:weblibre/core/logger.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_data.dart'; @@ -39,6 +41,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/dialogs/close_all_private_tabs_dialog.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/dialogs/close_all_tabs_dialog.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/dialogs/enable_ai_tab_suggestions_dialog.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart'; @@ -482,6 +485,7 @@ class TabViewHeader extends HookConsumerWidget { .closeContainerTabs( selectedContainerId, includeRegular: false, + includeIsolated: false, ); if (context.mounted) { @@ -499,6 +503,55 @@ class TabViewHeader extends HookConsumerWidget { }, child: const Text('Close Private Tabs'), ), + MenuItemButton( + leadingIcon: Icon( + MdiIcons.shieldLock, + color: AppColors.of(context).isolatedTabTeal, + ), + onPressed: isSyncedScope + ? null + : () async { + // Count distinct isolation groups that will be destroyed + final allStates = ref.read(tabStatesProvider); + final isolatedContextIds = allStates.values + .where( + (s) => + s.tabMode is IsolatedTabMode && + s.isolationContextId != null, + ) + .map((s) => s.isolationContextId!) + .toSet(); + + if (isolatedContextIds.isNotEmpty && + context.mounted) { + final confirmed = await ui_helper + .confirmIsolatedTabClose( + context, + groupCount: isolatedContextIds.length, + ); + if (!confirmed) return; + } + + final count = await ref + .read(tabDataRepositoryProvider.notifier) + .closeContainerTabs( + selectedContainerId, + includeRegular: false, + includePrivate: false, + ); + + if (context.mounted) { + ui_helper.showTabUndoClose( + context, + ref + .read(tabRepositoryProvider.notifier) + .undoClose, + count: count.length, + ); + } + }, + child: const Text('Close Isolated Tabs'), + ), const Divider(), MenuItemButton( leadingIcon: const Icon(MdiIcons.bookmarkPlusOutline), @@ -652,14 +705,23 @@ class TabViewHeader extends HookConsumerWidget { startLoading: true, parentId: parentId, private: - tab.isPrivate ?? false, + tab.tabMode == + TabModeDbValue.private, flags: LoadUrlFlags.NONE .toValue(), source: Internal.newTab .toValue(), - contextId: selectedContainer - .metadata - .contextualIdentity, + contextId: + tab.tabMode == + TabModeDbValue + .isolated + ? tab.isolationContextId ?? + selectedContainer + .metadata + .contextualIdentity + : selectedContainer + .metadata + .contextualIdentity, ); }).toList(), containerSelection: diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/copy_image.dart b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/copy_image.dart index ff2f9cbb..5c50f6db 100644 --- a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/copy_image.dart +++ b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/copy_image.dart @@ -24,6 +24,7 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; class CopyImage extends HookConsumerWidget { final HitResult hitResult; @@ -47,7 +48,7 @@ class CopyImage extends HookConsumerWidget { await GeckoDownloadsService().copyInternetResource( currentTab.id, url: url, - isPrivate: currentTab.isPrivate, + isPrivate: currentTab.tabMode is PrivateTabMode, referrerUrl: currentTab.url, ); diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_image_new_tab.dart b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_image_new_tab.dart index 0d9bc52a..11785863 100644 --- a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_image_new_tab.dart +++ b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_image_new_tab.dart @@ -22,10 +22,10 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/utils/ui_helper.dart'; @@ -45,12 +45,13 @@ class OpenImageInNewTab extends HookConsumerWidget { title: const Text('Open image in new tab'), onTap: () async { final currentTab = ref.read(selectedTabStateProvider); - final isPrivate = - currentTab?.isPrivate ?? - ref - .read(generalSettingsWithDefaultsProvider) - .defaultCreateTabType == - TabType.private; + final tabMode = + currentTab?.tabMode ?? + TabMode.fromTabType( + ref + .read(generalSettingsWithDefaultsProvider) + .defaultCreateTabType, + ); final tabId = await ref .read(tabRepositoryProvider.notifier) @@ -58,7 +59,7 @@ class OpenImageInNewTab extends HookConsumerWidget { url: hitResult.tryGetSource(), parentId: currentTab?.id, selectTab: false, - private: isPrivate, + tabMode: tabMode, ); if (context.mounted) { diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_in_container.dart b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_in_container.dart index fa7ce518..a8567191 100644 --- a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_in_container.dart +++ b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_in_container.dart @@ -32,6 +32,7 @@ import 'package:weblibre/features/geckoview/domain/entities/tab_container_select import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; @@ -89,12 +90,13 @@ class OpenInContainer extends HookConsumerWidget { if (selectedContainer == null || !context.mounted) return; final currentTab = ref.read(selectedTabStateProvider); - final isPrivate = - currentTab?.isPrivate ?? - ref - .read(generalSettingsWithDefaultsProvider) - .defaultCreateTabType == - TabType.private; + final tabMode = + currentTab?.tabMode ?? + TabMode.fromTabType( + ref + .read(generalSettingsWithDefaultsProvider) + .defaultCreateTabType, + ); final tabId = await ref .read(tabRepositoryProvider.notifier) @@ -102,7 +104,7 @@ class OpenInContainer extends HookConsumerWidget { url: hitResult.tryGetLink(), parentId: currentTab?.id, selectTab: false, - private: isPrivate, + tabMode: tabMode, containerSelection: TabContainerSelection.specific( selectedContainer, ), diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_new_tab.dart b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_new_tab.dart index c953d785..f1655dfa 100644 --- a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_new_tab.dart +++ b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_new_tab.dart @@ -22,10 +22,10 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/utils/ui_helper.dart'; @@ -45,12 +45,13 @@ class OpenInNewTab extends HookConsumerWidget { title: const Text('Open in new tab'), onTap: () async { final currentTab = ref.read(selectedTabStateProvider); - final isPrivate = - currentTab?.isPrivate ?? - ref - .read(generalSettingsWithDefaultsProvider) - .defaultCreateTabType == - TabType.private; + final tabMode = + currentTab?.tabMode ?? + TabMode.fromTabType( + ref + .read(generalSettingsWithDefaultsProvider) + .defaultCreateTabType, + ); final tabId = await ref .read(tabRepositoryProvider.notifier) @@ -58,7 +59,7 @@ class OpenInNewTab extends HookConsumerWidget { url: hitResult.tryGetLink(), parentId: currentTab?.id, selectTab: false, - private: isPrivate, + tabMode: tabMode, ); if (context.mounted) { diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/save_file.dart b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/save_file.dart index 7300c0b1..b137fc72 100644 --- a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/save_file.dart +++ b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/save_file.dart @@ -24,6 +24,7 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; class SaveFile extends HookConsumerWidget { final HitResult hitResult; @@ -48,7 +49,7 @@ class SaveFile extends HookConsumerWidget { currentTab.id, url: url, skipConfirmation: true, - isPrivate: currentTab.isPrivate, + isPrivate: currentTab.tabMode is PrivateTabMode, referrerUrl: currentTab.url, ); diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/save_image.dart b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/save_image.dart index d9a3503a..6a997582 100644 --- a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/save_image.dart +++ b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/save_image.dart @@ -24,6 +24,7 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; class SaveImage extends HookConsumerWidget { final HitResult hitResult; @@ -48,7 +49,7 @@ class SaveImage extends HookConsumerWidget { currentTab.id, url: url, skipConfirmation: true, - isPrivate: currentTab.isPrivate, + isPrivate: currentTab.tabMode is PrivateTabMode, referrerUrl: currentTab.url, ); diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/share_image.dart b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/share_image.dart index 7a26f1b0..93d9a5ec 100644 --- a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/share_image.dart +++ b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/share_image.dart @@ -24,6 +24,7 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; class ShareImage extends HookConsumerWidget { final HitResult hitResult; @@ -47,7 +48,7 @@ class ShareImage extends HookConsumerWidget { await GeckoDownloadsService().shareInternetResource( currentTab.id, url: url, - isPrivate: currentTab.isPrivate, + isPrivate: currentTab.tabMode is PrivateTabMode, referrerUrl: currentTab.url, ); diff --git a/app/lib/features/geckoview/features/history/presentation/screens/history.dart b/app/lib/features/geckoview/features/history/presentation/screens/history.dart index 36547c74..ac5fbbc9 100644 --- a/app/lib/features/geckoview/features/history/presentation/screens/history.dart +++ b/app/lib/features/geckoview/features/history/presentation/screens/history.dart @@ -33,13 +33,13 @@ import 'package:nullability/nullability.dart'; import 'package:path/path.dart' as p; import 'package:sliver_tools/sliver_tools.dart'; import 'package:timeago/timeago.dart' as timeago; - import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/delete_data.dart'; import 'package:weblibre/features/geckoview/features/history/domain/entities/history_filter_options.dart'; import 'package:weblibre/features/geckoview/features/history/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart'; import 'package:weblibre/features/geckoview/features/history/presentation/dialogs/delete_file.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/presentation/hooks/menu_controller.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart'; @@ -504,7 +504,7 @@ class HistoryScreen extends HookConsumerWidget { .read(tabRepositoryProvider.notifier) .addTab( url: Uri.parse(item.url), - private: false, + tabMode: TabMode.regular, selectTab: true, ); diff --git a/app/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart b/app/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart index 4ce38ee7..73032449 100644 --- a/app/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart +++ b/app/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart @@ -36,6 +36,7 @@ import 'package:weblibre/features/geckoview/features/open_link_tools/domain/serv import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/controllers/open_shared_content_unshorten_controller.dart'; import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/dialogs/tracking_details_dialog.dart'; import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/widgets/attribution_link.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; @@ -160,13 +161,13 @@ class OpenSharedContent extends HookConsumerWidget { showInfoMessage(context, 'URL preview applied'); } - Future openTab(bool isPrivate) async { + Future openTab(TabMode tabMode) async { if (formKey.currentState?.validate() == true) { await ref .read(tabRepositoryProvider.notifier) .addTab( url: Uri.parse(textController.text), - private: isPrivate, + tabMode: tabMode, containerSelection: selectedContainer.value == null ? const TabContainerSelection.unassigned() : TabContainerSelection.specific(selectedContainer.value!), @@ -359,16 +360,42 @@ class OpenSharedContent extends HookConsumerWidget { title: 'Open in new tab', subtitle: 'Add to your browser tabs', icon: MdiIcons.tab, - trailing: IconButton( - icon: Icon( - MdiIcons.dominoMask, - color: appColors.privateTabPurple, - size: 24, - ), - tooltip: 'Private', - onPressed: () => openTab(true), + trailing: PopupMenuButton( + icon: Icon(MdiIcons.tabUnselected, size: 24), + tooltip: 'Private / Isolated', + onSelected: openTab, + itemBuilder: (context) => [ + PopupMenuItem( + value: TabMode.private, + child: Row( + children: [ + Icon( + MdiIcons.dominoMask, + color: appColors.privateTabPurple, + size: 20, + ), + const SizedBox(width: 12), + const Text('Private'), + ], + ), + ), + PopupMenuItem( + value: TabMode.newIsolated(), + child: Row( + children: [ + Icon( + MdiIcons.shieldLock, + color: appColors.isolatedTabTeal, + size: 20, + ), + const SizedBox(width: 12), + const Text('Isolated'), + ], + ), + ), + ], ), - onTap: () => openTab(false), + onTap: () => openTab(TabMode.regular), ), _OpenActionTile( title: 'Open in custom tab', diff --git a/app/lib/features/geckoview/features/search/presentation/screens/search.dart b/app/lib/features/geckoview/features/search/presentation/screens/search.dart index 4fa38e28..3a70c53b 100644 --- a/app/lib/features/geckoview/features/search/presentation/screens/search.dart +++ b/app/lib/features/geckoview/features/search/presentation/screens/search.dart @@ -40,6 +40,8 @@ import 'package:weblibre/features/geckoview/features/search/presentation/widgets import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolation_context.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; @@ -92,15 +94,24 @@ class SearchScreen extends HookConsumerWidget { // Determine if we're in edit mode (tabId provided AND tab still exists) final isEditMode = tabId != null && existingTabState != null; - // Derive private mode from existing tab or from selector - final privateTabMode = isEditMode - ? existingTabState.isPrivate + final effectiveTabMode = isEditMode + ? existingTabState.tabMode : switch (selectedTabType.value) { - TabType.regular => false, - TabType.private => true, - TabType.child => currentTabTabType == TabType.private, + TabType.regular => TabMode.regular, + TabType.private => TabMode.private, + TabType.isolated => TabMode.newIsolated(), + TabType.child => switch (currentTabTabType) { + TabType.private => TabMode.private, + TabType.isolated => TabMode.isolated( + ref.watch(selectedTabStateProvider)?.isolationContextId ?? + newIsolatedContextId(), + ), + _ => TabMode.regular, + }, }; + final privateTabMode = effectiveTabMode is PrivateTabMode; + final searchTextController = useTextEditingController( text: initialSearchText, ); @@ -240,7 +251,7 @@ class SearchScreen extends HookConsumerWidget { .read(tabRepositoryProvider.notifier) .addTab( url: searchUri, - private: privateTabMode, + tabMode: effectiveTabMode, parentId: (selectedTabType.value == TabType.child) ? ref.read(selectedTabProvider) : null, @@ -279,7 +290,7 @@ class SearchScreen extends HookConsumerWidget { child: Row( children: [ Expanded( - flex: 3, + flex: 4, child: Align( alignment: Alignment.centerLeft, child: Focus( @@ -300,18 +311,24 @@ class SearchScreen extends HookConsumerWidget { TabType.regular => null, TabType.private => appColors.privateSelectionOverlay, + TabType.isolated => + appColors.isolatedSelectionOverlay, TabType.child => - (currentTabTabType == - TabType.private) - ? appColors - .privateSelectionOverlay - : null, + switch (currentTabTabType) { + TabType.private => + appColors + .privateSelectionOverlay, + TabType.isolated => + appColors + .isolatedSelectionOverlay, + _ => null, + }, }, ), ), ), ), - const SizedBox(width: 16), + const SizedBox(width: 8), Flexible( flex: 2, child: Align( @@ -390,7 +407,7 @@ class SearchScreen extends HookConsumerWidget { .read(tabRepositoryProvider.notifier) .addTab( url: newUrl, - private: privateTabMode, + tabMode: effectiveTabMode, parentId: (selectedTabType.value == TabType.child) ? ref.read(selectedTabProvider) @@ -447,7 +464,7 @@ class SearchScreen extends HookConsumerWidget { .read(tabRepositoryProvider.notifier) .addTab( url: uri, - private: privateTabMode, + tabMode: effectiveTabMode, parentId: (selectedTabType.value == TabType.child) ? ref.read(selectedTabProvider) : null, diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/animated_tab_type_switcher.dart b/app/lib/features/geckoview/features/search/presentation/widgets/animated_tab_type_switcher.dart index b1b82747..05f47e2e 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/animated_tab_type_switcher.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/animated_tab_type_switcher.dart @@ -72,6 +72,15 @@ class AnimatedTabTypeSwitcher extends StatelessWidget { selectedBackgroundColor: selectedBackgroundColor, onTap: () => onChanged(TabType.private), ), + _divider(borderColor), + _Segment( + tabType: TabType.isolated, + icon: MdiIcons.shieldLock, + label: 'Isolated', + isSelected: selected == TabType.isolated, + selectedBackgroundColor: selectedBackgroundColor, + onTap: () => onChanged(TabType.isolated), + ), if (showChildOption) ...[ _divider(borderColor), _Segment( diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart index a1f709b2..394cc020 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart @@ -75,9 +75,7 @@ class FullSearchTermSuggestions extends HookConsumerWidget { : [ if (searchTextIsNotEmpty) searchText, if (searchSuggestions.value != null) - ...searchSuggestions.value!.whereNot( - (s) => s == searchText, - ), + ...searchSuggestions.value!.whereNot((s) => s == searchText), ], [showHistory, searchText, searchHistory.value, searchSuggestions.value], ); @@ -115,9 +113,7 @@ class FullSearchTermSuggestions extends HookConsumerWidget { onPressed: ref .read(searchSuggestionsExpandedProvider.notifier) .toggle, - icon: Icon( - expanded ? Icons.unfold_less : Icons.unfold_more, - ), + icon: Icon(expanded ? Icons.unfold_less : Icons.unfold_more), ), ), ], diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart index c15f06bb..6f2313ed 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart @@ -105,18 +105,14 @@ class SearchModuleHeader extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Text( - isExpanded - ? 'Show less' - : 'Show all $totalCount', + isExpanded ? 'Show less' : 'Show all $totalCount', style: Theme.of(context).textTheme.labelSmall?.copyWith( color: Theme.of(context).colorScheme.primary, ), ), const SizedBox(width: 4), Icon( - isExpanded - ? Icons.expand_less - : Icons.expand_more, + isExpanded ? Icons.expand_less : Icons.expand_more, size: 16, color: Theme.of(context).colorScheme.primary, ), diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart index 84f8ba43..8920a00c 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart @@ -135,132 +135,135 @@ class TabSearch extends HookConsumerWidget { title: 'Tabs', moduleType: SearchModuleType.tabs, totalCount: filteredResultCount, - contentSliverBuilder: ({ - required bool isCollapsed, - required int visibleCount, - }) => [ - if (!isCollapsed) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ContainerChips( - displayMenu: false, - selectedContainer: selectedContainer.value, - showUnassignedChip: containerIdsWithResults.value - .containsKey(null), - onSelected: (container) { - selectedContainer.value = container; - }, - onDeleted: (container) { - selectedContainer.value = null; - }, - containerFilter: (container) => - containerIdsWithResults.value.containsKey(container.id), - containerBadgeCount: (container) => - containerIdsWithResults.value[container?.id] ?? 0, - searchTextListenable: searchTextListenable, + contentSliverBuilder: + ({required bool isCollapsed, required int visibleCount}) => [ + if (!isCollapsed) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ContainerChips( + displayMenu: false, + selectedContainer: selectedContainer.value, + showUnassignedChip: containerIdsWithResults.value + .containsKey(null), + onSelected: (container) { + selectedContainer.value = container; + }, + onDeleted: (container) { + selectedContainer.value = null; + }, + containerFilter: (container) => containerIdsWithResults + .value + .containsKey(container.id), + containerBadgeCount: (container) => + containerIdsWithResults.value[container?.id] ?? 0, + searchTextListenable: searchTextListenable, + ), + ], ), - ], + ), ), - ), - ), - if (!isCollapsed) - SliverList.builder( - itemCount: visibleCount, - itemBuilder: (context, index) { - final result = filteredTabs[index]; + if (!isCollapsed) + SliverList.builder( + itemCount: visibleCount, + itemBuilder: (context, index) { + final result = filteredTabs[index]; - final content = - (result.extractedContent?.contains(_matchPrefix) == true) - ? result.extractedContent - : result.fullContent; + final content = + (result.extractedContent?.contains(_matchPrefix) == true) + ? result.extractedContent + : result.fullContent; - final titleHasMatch = result.title.contains(_matchPrefix); - final urlHasMatch = - result.highlightedUrl?.contains(_matchPrefix) ?? false; - final bodyHasMatch = content?.contains(_matchPrefix) ?? false; + final titleHasMatch = result.title.contains(_matchPrefix); + final urlHasMatch = + result.highlightedUrl?.contains(_matchPrefix) ?? false; + final bodyHasMatch = content?.contains(_matchPrefix) ?? false; - return ListTile( - leading: RepaintBoundary( - child: - result.icon.mapNotNull( - (icon) => SafeRawImage( - image: icon, - height: 24, - width: 24, - fallback: UrlIcon([result.url], iconSize: 24), - ), - ) ?? - UrlIcon([result.url], iconSize: 24), - ), - title: result.title.mapNotNull( - (title) => Text.rich( - buildHighlightedText( - title, - Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - ), - Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.bold, - ), - _matchPrefix, - _matchSuffix, + return ListTile( + leading: RepaintBoundary( + child: + result.icon.mapNotNull( + (icon) => SafeRawImage( + image: icon, + height: 24, + width: 24, + fallback: UrlIcon([result.url], iconSize: 24), + ), + ) ?? + UrlIcon([result.url], iconSize: 24), ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - subtitle: (bodyHasMatch || (urlHasMatch && !titleHasMatch)) - ? Text.rich( + title: result.title.mapNotNull( + (title) => Text.rich( buildHighlightedText( - (bodyHasMatch ? content! : result.highlightedUrl!), - Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, + title, + Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurface, ), - Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, + Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold, ), _matchPrefix, _matchSuffix, - normalizeWhitespaces: true, ), - maxLines: 3, + maxLines: 2, overflow: TextOverflow.ellipsis, - ) - : UriBreadcrumb(uri: result.url), - onTap: () async { - await ref - .read(tabRepositoryProvider.notifier) - .selectTab(result.id); - if (result.sourceSearchQuery.isNotEmpty && - ref.read(findInPageControllerProvider(result.id)) == - FindInPageState.hidden()) { - await ref - .read(findInPageControllerProvider(result.id).notifier) - .findAll(text: result.sourceSearchQuery!); - } + ), + ), + subtitle: (bodyHasMatch || (urlHasMatch && !titleHasMatch)) + ? Text.rich( + buildHighlightedText( + (bodyHasMatch + ? content! + : result.highlightedUrl!), + Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.bold, + ), + _matchPrefix, + _matchSuffix, + normalizeWhitespaces: true, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ) + : UriBreadcrumb(uri: result.url), + onTap: () async { + await ref + .read(tabRepositoryProvider.notifier) + .selectTab(result.id); + if (result.sourceSearchQuery.isNotEmpty && + ref.read(findInPageControllerProvider(result.id)) == + FindInPageState.hidden()) { + await ref + .read( + findInPageControllerProvider(result.id).notifier, + ) + .findAll(text: result.sourceSearchQuery!); + } - if (context.mounted) { - ref - .read(bottomSheetControllerProvider.notifier) - .requestDismiss(); + if (context.mounted) { + ref + .read(bottomSheetControllerProvider.notifier) + .requestDismiss(); - const BrowserRoute().go(context); - } + const BrowserRoute().go(context); + } + }, + ); }, - ); - }, - ), - ], + ), + ], ); } } diff --git a/app/lib/features/geckoview/features/tabs/data/database/daos/container.dart b/app/lib/features/geckoview/features/tabs/data/database/daos/container.dart index 58695e67..86465018 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/daos/container.dart +++ b/app/lib/features/geckoview/features/tabs/data/database/daos/container.dart @@ -24,6 +24,7 @@ import 'package:drift/drift.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/container.drift.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart'; @@ -75,18 +76,17 @@ class ContainerDao extends DatabaseAccessor Selectable getAllTabIds({ bool includeRegular = true, bool includePrivate = true, + bool includeIsolated = true, }) { final query = selectOnly(db.tab)..addColumns([db.tab.id]); - if (!includeRegular) { - query.where( - db.tab.isPrivate.isNotNull() & db.tab.isPrivate.isNotValue(false), - ); - } - if (!includePrivate) { - query.where( - db.tab.isPrivate.isNotNull() & db.tab.isPrivate.isNotValue(true), - ); + final excludedModes = []; + if (!includeRegular) excludedModes.add(TabModeDbValue.regular); + if (!includePrivate) excludedModes.add(TabModeDbValue.private); + if (!includeIsolated) excludedModes.add(TabModeDbValue.isolated); + + if (excludedModes.isNotEmpty) { + query.where(db.tab.tabMode.isNotInValues(excludedModes)); } return query.map((row) => row.read(db.tab.id)!); @@ -96,6 +96,7 @@ class ContainerDao extends DatabaseAccessor String? containerId, { bool includeRegular = true, bool includePrivate = true, + bool includeIsolated = true, }) { final query = selectOnly(db.tab) ..addColumns([db.tab.id]) @@ -106,15 +107,13 @@ class ContainerDao extends DatabaseAccessor ) ..orderBy([OrderingTerm.asc(db.tab.orderKey)]); - if (!includeRegular) { - query.where( - db.tab.isPrivate.isNotNull() & db.tab.isPrivate.isNotValue(false), - ); - } - if (!includePrivate) { - query.where( - db.tab.isPrivate.isNotNull() & db.tab.isPrivate.isNotValue(true), - ); + final excludedModes = []; + if (!includeRegular) excludedModes.add(TabModeDbValue.regular); + if (!includePrivate) excludedModes.add(TabModeDbValue.private); + if (!includeIsolated) excludedModes.add(TabModeDbValue.isolated); + + if (excludedModes.isNotEmpty) { + query.where(db.tab.tabMode.isNotInValues(excludedModes)); } return query.map((row) => row.read(db.tab.id)!); diff --git a/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart b/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart index 05cf8b03..7b8b3705 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart +++ b/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart @@ -26,11 +26,22 @@ import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/tab.drift.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_source.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/tab_query_result.dart'; @DriftAccessor() +class SyncTabsResult { + final Set deletedIsolationContextIds; + final int deletedCount; + + const SyncTabsResult({ + required this.deletedIsolationContextIds, + required this.deletedCount, + }); +} + class TabDao extends DatabaseAccessor with $TabDaoMixin { final _undoHistory = {}; Timer? _clearHistoryTimer; @@ -45,12 +56,25 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { SingleOrNullSelectable getTabDataById(String id) => db.tab.select()..where((t) => t.id.equals(id)); - SingleOrNullSelectable getTabIsPrivate(String tabId) { + SingleOrNullSelectable getTabMode(String tabId) { final query = selectOnly(db.tab) - ..addColumns([db.tab.isPrivate]) + ..addColumns([db.tab.tabMode, db.tab.isolationContextId]) ..where(db.tab.id.equals(tabId)); - return query.map((row) => row.read(db.tab.isPrivate)); + return query.map( + (row) => TabMode.fromDbValue( + row.readWithConverter(db.tab.tabMode)!, + isolationContextId: row.read(db.tab.isolationContextId), + ), + ); + } + + SingleOrNullSelectable getTabIsolationContextId(String tabId) { + final query = selectOnly(db.tab) + ..addColumns([db.tab.isolationContextId]) + ..where(db.tab.id.equals(tabId)); + + return query.map((row) => row.read(db.tab.isolationContextId)); } Selectable getAllTabIds() { @@ -124,18 +148,24 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { Future upsertTabTransactional( Future Function() createTab, { - required Value isPrivate, required Value parentId, Value containerId = const Value.absent(), Value orderKey = const Value.absent(), Value url = const Value.absent(), Value title = const Value.absent(), + Value tabMode = const Value.absent(), }) { return db.transaction(() async { final tabId = await createTab(); final currentOrderKey = orderKey.value ?? await _generateOrderKey(parentId: parentId, containerId: containerId); + final Value persistedTabMode = tabMode.present + ? Value(tabMode.value.toDbValue()) + : const Value.absent(); + final Value isolationContextId = tabMode.present + ? Value(tabMode.value.isolationContextId) + : const Value.absent(); await db.tab.insertOne( TabCompanion.insert( @@ -146,8 +176,9 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { containerId: containerId, url: url, title: title, - isPrivate: isPrivate, orderKey: currentOrderKey, + tabMode: persistedTabMode, + isolationContextId: isolationContextId, ), onConflict: DoUpdate( (old) => TabCompanion( @@ -156,8 +187,9 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { containerId: containerId, url: url, title: title, - isPrivate: isPrivate, orderKey: Value.absentIfNull(orderKey.value), + tabMode: persistedTabMode, + isolationContextId: isolationContextId, ), ), ); @@ -170,17 +202,23 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { Future insertTab( String tabId, { required TabSource source, - required Value isPrivate, required Value parentId, Value containerId = const Value.absent(), Value orderKey = const Value.absent(), Value url = const Value.absent(), Value title = const Value.absent(), + Value tabMode = const Value.absent(), }) { return db.transaction(() async { final currentOrderKey = orderKey.value ?? await _generateOrderKey(parentId: parentId, containerId: containerId); + final Value persistedTabMode = tabMode.present + ? Value(tabMode.value.toDbValue()) + : const Value.absent(); + final Value isolationContextId = tabMode.present + ? Value(tabMode.value.isolationContextId) + : const Value.absent(); await db.tab.insertOne( TabCompanion.insert( @@ -190,9 +228,10 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { timestamp: DateTime.now(), containerId: containerId, orderKey: currentOrderKey, - isPrivate: isPrivate, url: url, title: title, + tabMode: persistedTabMode, + isolationContextId: isolationContextId, ), onConflict: DoUpdate( (old) => TabCompanion( @@ -201,8 +240,9 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { containerId: containerId, url: url, title: title, - isPrivate: isPrivate, orderKey: Value.absentIfNull(orderKey.value), + tabMode: persistedTabMode, + isolationContextId: isolationContextId, ), where: (old) => old.source.isSmallerThanValue(source.index), ), @@ -301,7 +341,8 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { if (previousState == null || previousState.url != state.url || previousState.title != state.title || - previousState.parentId != state.parentId) { + previousState.parentId != state.parentId || + previousState.tabMode != state.tabMode) { batch.update( db.tab, TabCompanion( @@ -314,6 +355,12 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { title: (previousState?.title != state.title) ? Value(state.title) : const Value.absent(), + tabMode: (previousState?.tabMode != state.tabMode) + ? Value(state.tabMode.toDbValue()) + : const Value.absent(), + isolationContextId: (previousState?.tabMode != state.tabMode) + ? Value(state.isolationContextId) + : const Value.absent(), ), where: (t) => t.id.equals(state.id), ); @@ -323,12 +370,19 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { }); } - Future syncTabs({required List retainTabIds}) { + /// Syncs DB tab rows with the engine's active tab list. + /// Returns metadata about deleted rows for follow-up cleanup. + Future syncTabs({required List retainTabIds}) { return db.transaction(() async { final deleted = await (db.tab.delete()..where((t) => t.id.isNotIn(retainTabIds))) .goAndReturn(); + final deletedIsolationContextIds = { + for (final tab in deleted) + if (tab.isolationContextId != null) tab.isolationContextId!, + }; + if (deleted.isNotEmpty) { _clearHistoryTimer?.cancel(); @@ -361,6 +415,11 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { }), onConflict: DoNothing(), ); + + return SyncTabsResult( + deletedIsolationContextIds: deletedIsolationContextIds, + deletedCount: deleted.length, + ); }); } @@ -391,6 +450,19 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { } } + SingleSelectable tabsInIsolationGroup(String contextId) { + return db.definitionsDrift.tabsInIsolationGroup(contextId: contextId); + } + + Selectable allIsolationContextIds() { + return db.definitionsDrift.allIsolationContextIds(); + } + + Selectable + isolatedContextContainerPairs() { + return db.definitionsDrift.isolatedContextContainerPairs(); + } + Future> getUnassignedTabsOlderThan(DateTime threshold) { final query = selectOnly(db.tab) ..addColumns([db.tab.id]) diff --git a/app/lib/features/geckoview/features/tabs/data/database/database.dart b/app/lib/features/geckoview/features/tabs/data/database/database.dart index 22473682..1992efbd 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/database.dart +++ b/app/lib/features/geckoview/features/tabs/data/database/database.dart @@ -31,7 +31,7 @@ import 'package:weblibre/features/search/domain/fts_tokenizer.dart'; @DriftDatabase(include: {'definitions.drift'}, daos: [ContainerDao, TabDao]) class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin { @override - final int schemaVersion = 5; + final int schemaVersion = 6; @override final int ftsTokenLimit = 10; @@ -99,5 +99,21 @@ class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin { await m.drop(schema.tabMaintainParentChainOnDelete); await m.create(schema.tabMaintainParentChainOnDelete); }, + from5To6: (m, schema) async { + await m.alterTable( + TableMigration( + schema.tab, + columnTransformer: { + // Backfill tab_mode from is_private: private=true -> 1 (private), else -> 0 (regular) + schema.tab.tabMode: const CustomExpression( + 'CASE WHEN is_private = 1 THEN 1 ELSE 0 END', + ), + }, + newColumns: [schema.tab.tabMode, schema.tab.isolationContextId], + ), + ); + + await m.alterTable(TableMigration(schema.tab)); + }, ); } diff --git a/app/lib/features/geckoview/features/tabs/data/database/database.steps.dart b/app/lib/features/geckoview/features/tabs/data/database/database.steps.dart index 77c550b2..b27dc10c 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/database.steps.dart +++ b/app/lib/features/geckoview/features/tabs/data/database/database.steps.dart @@ -1,9 +1,10 @@ // dart format width=80 import 'package:drift/internal/versioned_schema.dart' as i0; import 'package:drift/drift.dart' as i1; -import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import +import 'package:drift/drift.dart'; // GENERATED BY drift_dev, DO NOT MODIFY. -// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// final class Schema3 extends i0.VersionedSchema { Schema3({required super.database}) : super(version: 3); @override @@ -461,10 +462,143 @@ final class Schema5 extends i0.VersionedSchema { ); } +final class Schema6 extends i0.VersionedSchema { + Schema6({required super.database}) : super(version: 6); + @override + late final List entities = [ + container, + tab, + tabFts, + tabMaintainParentChainOnDelete, + tabAfterInsert, + tabAfterDelete, + tabAfterUpdate, + ]; + late final Shape0 container = Shape0( + source: i0.VersionedTable( + entityName: 'container', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_0, _column_1, _column_2, _column_3], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape4 tab = Shape4( + source: i0.VersionedTable( + entityName: 'tab', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))', + ], + columns: [ + _column_0, + _column_16, + _column_4, + _column_5, + _column_6, + _column_7, + _column_8, + _column_17, + _column_18, + _column_10, + _column_11, + _column_12, + _column_13, + _column_14, + _column_15, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape2 tabFts = Shape2( + source: i0.VersionedVirtualTable( + entityName: 'tab_fts', + moduleAndArgs: + 'fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize="trigram")', + columns: [_column_8, _column_7, _column_12, _column_14], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Trigger tabMaintainParentChainOnDelete = i1.Trigger( + 'CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END', + 'tab_maintain_parent_chain_on_delete', + ); + final i1.Trigger tabAfterInsert = i1.Trigger( + 'CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_insert', + ); + final i1.Trigger tabAfterDelete = i1.Trigger( + 'CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END', + 'tab_after_delete', + ); + final i1.Trigger tabAfterUpdate = i1.Trigger( + 'CREATE TRIGGER tab_after_update AFTER UPDATE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_update', + ); +} + +class Shape4 extends i0.VersionedTable { + Shape4({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get source => + columnsByName['source']! as i1.GeneratedColumn; + i1.GeneratedColumn get parentId => + columnsByName['parent_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get containerId => + columnsByName['container_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get orderKey => + columnsByName['order_key']! as i1.GeneratedColumn; + i1.GeneratedColumn get url => + columnsByName['url']! as i1.GeneratedColumn; + i1.GeneratedColumn get title => + columnsByName['title']! as i1.GeneratedColumn; + i1.GeneratedColumn get tabMode => + columnsByName['tab_mode']! as i1.GeneratedColumn; + i1.GeneratedColumn get isolationContextId => + columnsByName['isolation_context_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get isProbablyReaderable => + columnsByName['is_probably_readerable']! as i1.GeneratedColumn; + i1.GeneratedColumn get extractedContentMarkdown => + columnsByName['extracted_content_markdown']! + as i1.GeneratedColumn; + i1.GeneratedColumn get extractedContentPlain => + columnsByName['extracted_content_plain']! as i1.GeneratedColumn; + i1.GeneratedColumn get fullContentMarkdown => + columnsByName['full_content_markdown']! as i1.GeneratedColumn; + i1.GeneratedColumn get fullContentPlain => + columnsByName['full_content_plain']! as i1.GeneratedColumn; + i1.GeneratedColumn get timestamp => + columnsByName['timestamp']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_17(String aliasedName) => + i1.GeneratedColumn( + 'tab_mode', + aliasedName, + false, + type: i1.DriftSqlType.int, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const i1.CustomExpression('0'), + ); +i1.GeneratedColumn _column_18(String aliasedName) => + i1.GeneratedColumn( + 'isolation_context_id', + aliasedName, + true, + type: i1.DriftSqlType.string, + $customConstraints: '', + ); i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema3 schema) from2To3, required Future Function(i1.Migrator m, Schema4 schema) from3To4, required Future Function(i1.Migrator m, Schema5 schema) from4To5, + required Future Function(i1.Migrator m, Schema6 schema) from5To6, }) { return (currentVersion, database) async { switch (currentVersion) { @@ -483,6 +617,11 @@ i0.MigrationStepWithVersion migrationSteps({ final migrator = i1.Migrator(database, schema); await from4To5(migrator, schema); return 5; + case 5: + final schema = Schema6(database: database); + final migrator = i1.Migrator(database, schema); + await from5To6(migrator, schema); + return 6; default: throw ArgumentError.value('Unknown migration from $currentVersion'); } @@ -493,10 +632,12 @@ i1.OnUpgrade stepByStep({ required Future Function(i1.Migrator m, Schema3 schema) from2To3, required Future Function(i1.Migrator m, Schema4 schema) from3To4, required Future Function(i1.Migrator m, Schema5 schema) from4To5, + required Future Function(i1.Migrator m, Schema6 schema) from5To6, }) => i0.VersionedSchema.stepByStepHelper( step: migrationSteps( from2To3: from2To3, from3To4: from3To4, from4To5: from4To5, + from5To6: from5To6, ), ); diff --git a/app/lib/features/geckoview/features/tabs/data/database/definitions.drift b/app/lib/features/geckoview/features/tabs/data/database/definitions.drift index 7a9f0ef8..585843a8 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/definitions.drift +++ b/app/lib/features/geckoview/features/tabs/data/database/definitions.drift @@ -4,6 +4,7 @@ import 'package:weblibre/features/geckoview/features/tabs/data/models/container_ import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/tab_query_result.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/converters/container_metadata_converter.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_source.dart'; CREATE TABLE container ( @@ -13,7 +14,7 @@ CREATE TABLE container ( metadata TEXT MAPPED BY `const ContainerMetadataConverter()` ) WITH ContainerData; -CREATE TABLE tab( +CREATE TABLE tab( id TEXT PRIMARY KEY NOT NULL, source ENUM(TabSource) NOT NULL, parent_id TEXT REFERENCES tab (id) ON DELETE SET NULL, @@ -21,13 +22,18 @@ CREATE TABLE tab( order_key TEXT NOT NULL, url TEXT MAPPED BY `const UriConverterNullable()`, title TEXT, - is_private BOOL, + tab_mode ENUM(TabModeDbValue) NOT NULL DEFAULT 0, + isolation_context_id TEXT, is_probably_readerable BOOL, extracted_content_markdown TEXT, extracted_content_plain TEXT, full_content_markdown TEXT, full_content_plain TEXT, - timestamp DATETIME NOT NULL + timestamp DATETIME NOT NULL, + CHECK ( + (tab_mode = 2 AND isolation_context_id IS NOT NULL) OR + (tab_mode != 2 AND isolation_context_id IS NULL) + ) ); CREATE VIRTUAL TABLE tab_fts @@ -142,58 +148,58 @@ orderKeyBeforeTab(:tab_id AS TEXT, REQUIRED :container_id AS TEXT OR NULL): queryTabsBasic WITH TabQueryResult: WITH weights AS ( - SELECT + SELECT -- Customize these weights (higher = more important) 10.0 as title_weight, -- Title matches are most important 5.0 as url_weight -- URL matches are quite important ) - SELECT + SELECT t.id, t.container_id, - t.is_private, + t.tab_mode, t.title, CAST(t.url AS TEXT) AS url, t.url AS clean_url, bm25(tab_fts, weights.title_weight, weights.url_weight) AS weighted_rank FROM tab_fts fts INNER JOIN - tab t ON t.rowid = fts.rowid + tab t ON t.rowid = fts.rowid CROSS JOIN weights - WHERE + WHERE fts.title LIKE :query OR fts.url LIKE :query - ORDER BY + ORDER BY weighted_rank ASC, t.timestamp DESC LIMIT :limit; queryTabsFullContent WITH TabQueryResult: WITH weights AS ( - SELECT + SELECT -- Customize these weights (higher = more important) 10.0 as title_weight, -- Title matches are most important 5.0 as url_weight, -- URL matches are quite important 3.0 as extracted_weight, -- Extracted content matches 1.0 as full_weight -- Full content matches less important ) - SELECT + SELECT t.id, t.container_id, - t.is_private, + t.tab_mode, highlight(tab_fts, 0, :beforeMatch, :afterMatch) AS title, highlight(tab_fts, 1, :beforeMatch, :afterMatch) AS url, snippet(tab_fts, 2, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS extracted_content, snippet(tab_fts, 3, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS full_content, t.url AS clean_url, ( - bm25(tab_fts, weights.title_weight, weights.url_weight, + bm25(tab_fts, weights.title_weight, weights.url_weight, weights.extracted_weight, weights.full_weight) ) AS weighted_rank FROM tab_fts(:query) fts INNER JOIN tab t ON t.rowid = fts.rowid CROSS JOIN weights - ORDER BY + ORDER BY weighted_rank ASC, t.timestamp DESC LIMIT :limit; @@ -334,3 +340,16 @@ containersToClearOnExit: WHERE json_extract(container.metadata, '$.clearDataOnExit') = 1 AND container.metadata ->> '$.contextualIdentity' IS NOT NULL; + +tabsInIsolationGroup: + SELECT COUNT(*) AS count FROM tab WHERE isolation_context_id = :contextId; + +allIsolationContextIds: + SELECT DISTINCT isolation_context_id FROM tab WHERE isolation_context_id IS NOT NULL; + +isolatedContextContainerPairs: + SELECT DISTINCT t.isolation_context_id, t.container_id + FROM tab t + WHERE t.tab_mode = 2 + AND t.isolation_context_id IS NOT NULL + AND t.container_id IS NOT NULL; diff --git a/app/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart b/app/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart index f855544b..927a7245 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart +++ b/app/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart @@ -11,12 +11,14 @@ import 'package:weblibre/features/geckoview/features/tabs/data/database/converte as i5; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_source.dart' as i6; -import 'package:weblibre/data/database/converters/uri.dart' as i7; -import 'package:drift/internal/modular.dart' as i8; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart' + as i7; +import 'package:weblibre/data/database/converters/uri.dart' as i8; +import 'package:drift/internal/modular.dart' as i9; import 'package:weblibre/features/geckoview/features/tabs/data/models/tab_query_result.dart' - as i9; -import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart' as i10; +import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart' + as i11; typedef $ContainerCreateCompanionBuilder = i3.ContainerCompanion Function({ @@ -47,10 +49,10 @@ final class $ContainerReferences static i0.MultiTypedResultKey> _tabRefsTable( i0.GeneratedDatabase db, ) => i0.MultiTypedResultKey.fromTable( - i8.ReadDatabaseContainer(db).resultSet('tab'), + i9.ReadDatabaseContainer(db).resultSet('tab'), aliasName: i0.$_aliasNameGenerator( - i8.ReadDatabaseContainer(db).resultSet('container').id, - i8.ReadDatabaseContainer(db).resultSet('tab').containerId, + i9.ReadDatabaseContainer(db).resultSet('container').id, + i9.ReadDatabaseContainer(db).resultSet('tab').containerId, ), ); @@ -58,7 +60,7 @@ final class $ContainerReferences final manager = i3 .$TabTableManager( $_db, - i8.ReadDatabaseContainer($_db).resultSet('tab'), + i9.ReadDatabaseContainer($_db).resultSet('tab'), ) .filter((f) => f.containerId.id.sqlEquals($_itemColumn('id')!)); @@ -110,7 +112,7 @@ class $ContainerFilterComposer final i3.$TabFilterComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.id, - referencedTable: i8.ReadDatabaseContainer($db).resultSet('tab'), + referencedTable: i9.ReadDatabaseContainer($db).resultSet('tab'), getReferencedColumn: (t) => t.containerId, builder: ( @@ -119,7 +121,7 @@ class $ContainerFilterComposer $removeJoinBuilderFromRootComposer, }) => i3.$TabFilterComposer( $db: $db, - $table: i8.ReadDatabaseContainer($db).resultSet('tab'), + $table: i9.ReadDatabaseContainer($db).resultSet('tab'), $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -188,7 +190,7 @@ class $ContainerAnnotationComposer final i3.$TabAnnotationComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.id, - referencedTable: i8.ReadDatabaseContainer($db).resultSet('tab'), + referencedTable: i9.ReadDatabaseContainer($db).resultSet('tab'), getReferencedColumn: (t) => t.containerId, builder: ( @@ -197,7 +199,7 @@ class $ContainerAnnotationComposer $removeJoinBuilderFromRootComposer, }) => i3.$TabAnnotationComposer( $db: $db, - $table: i8.ReadDatabaseContainer($db).resultSet('tab'), + $table: i9.ReadDatabaseContainer($db).resultSet('tab'), $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -275,7 +277,7 @@ class $ContainerTableManager db: db, explicitlyWatchedTables: [ if (tabRefs) - i8.ReadDatabaseContainer(db).resultSet('tab'), + i9.ReadDatabaseContainer(db).resultSet('tab'), ], addJoins: null, getPrefetchedDataCallback: (items) async { @@ -329,7 +331,8 @@ typedef $TabCreateCompanionBuilder = required String orderKey, i0.Value url, i0.Value title, - i0.Value isPrivate, + i0.Value tabMode, + i0.Value isolationContextId, i0.Value isProbablyReaderable, i0.Value extractedContentMarkdown, i0.Value extractedContentPlain, @@ -347,7 +350,8 @@ typedef $TabUpdateCompanionBuilder = i0.Value orderKey, i0.Value url, i0.Value title, - i0.Value isPrivate, + i0.Value tabMode, + i0.Value isolationContextId, i0.Value isProbablyReaderable, i0.Value extractedContentMarkdown, i0.Value extractedContentPlain, @@ -362,12 +366,12 @@ final class $TabReferences $TabReferences(super.$_db, super.$_table, super.$_typedResult); static i3.Container _containerIdTable(i0.GeneratedDatabase db) => - i8.ReadDatabaseContainer(db) + i9.ReadDatabaseContainer(db) .resultSet('container') .createAlias( i0.$_aliasNameGenerator( - i8.ReadDatabaseContainer(db).resultSet('tab').containerId, - i8.ReadDatabaseContainer( + i9.ReadDatabaseContainer(db).resultSet('tab').containerId, + i9.ReadDatabaseContainer( db, ).resultSet('container').id, ), @@ -379,7 +383,7 @@ final class $TabReferences final manager = i3 .$ContainerTableManager( $_db, - i8.ReadDatabaseContainer($_db).resultSet('container'), + i9.ReadDatabaseContainer($_db).resultSet('container'), ) .filter((f) => f.id.sqlEquals($_column)); final item = $_typedResult.readTableOrNull(_containerIdTable($_db)); @@ -430,8 +434,14 @@ class $TabFilterComposer extends i0.Composer { builder: (column) => i0.ColumnFilters(column), ); - i0.ColumnFilters get isPrivate => $composableBuilder( - column: $table.isPrivate, + i0.ColumnWithTypeConverterFilters + get tabMode => $composableBuilder( + column: $table.tabMode, + builder: (column) => i0.ColumnWithTypeConverterFilters(column), + ); + + i0.ColumnFilters get isolationContextId => $composableBuilder( + column: $table.isolationContextId, builder: (column) => i0.ColumnFilters(column), ); @@ -469,7 +479,7 @@ class $TabFilterComposer extends i0.Composer { final i3.$ContainerFilterComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.containerId, - referencedTable: i8.ReadDatabaseContainer( + referencedTable: i9.ReadDatabaseContainer( $db, ).resultSet('container'), getReferencedColumn: (t) => t.id, @@ -480,7 +490,7 @@ class $TabFilterComposer extends i0.Composer { $removeJoinBuilderFromRootComposer, }) => i3.$ContainerFilterComposer( $db: $db, - $table: i8.ReadDatabaseContainer( + $table: i9.ReadDatabaseContainer( $db, ).resultSet('container'), $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, @@ -531,8 +541,13 @@ class $TabOrderingComposer extends i0.Composer { builder: (column) => i0.ColumnOrderings(column), ); - i0.ColumnOrderings get isPrivate => $composableBuilder( - column: $table.isPrivate, + i0.ColumnOrderings get tabMode => $composableBuilder( + column: $table.tabMode, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get isolationContextId => $composableBuilder( + column: $table.isolationContextId, builder: (column) => i0.ColumnOrderings(column), ); @@ -570,7 +585,7 @@ class $TabOrderingComposer extends i0.Composer { final i3.$ContainerOrderingComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.containerId, - referencedTable: i8.ReadDatabaseContainer( + referencedTable: i9.ReadDatabaseContainer( $db, ).resultSet('container'), getReferencedColumn: (t) => t.id, @@ -581,7 +596,7 @@ class $TabOrderingComposer extends i0.Composer { $removeJoinBuilderFromRootComposer, }) => i3.$ContainerOrderingComposer( $db: $db, - $table: i8.ReadDatabaseContainer( + $table: i9.ReadDatabaseContainer( $db, ).resultSet('container'), $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, @@ -620,8 +635,13 @@ class $TabAnnotationComposer extends i0.Composer { i0.GeneratedColumn get title => $composableBuilder(column: $table.title, builder: (column) => column); - i0.GeneratedColumn get isPrivate => - $composableBuilder(column: $table.isPrivate, builder: (column) => column); + i0.GeneratedColumnWithTypeConverter get tabMode => + $composableBuilder(column: $table.tabMode, builder: (column) => column); + + i0.GeneratedColumn get isolationContextId => $composableBuilder( + column: $table.isolationContextId, + builder: (column) => column, + ); i0.GeneratedColumn get isProbablyReaderable => $composableBuilder( column: $table.isProbablyReaderable, @@ -655,7 +675,7 @@ class $TabAnnotationComposer extends i0.Composer { final i3.$ContainerAnnotationComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.containerId, - referencedTable: i8.ReadDatabaseContainer( + referencedTable: i9.ReadDatabaseContainer( $db, ).resultSet('container'), getReferencedColumn: (t) => t.id, @@ -666,7 +686,7 @@ class $TabAnnotationComposer extends i0.Composer { $removeJoinBuilderFromRootComposer, }) => i3.$ContainerAnnotationComposer( $db: $db, - $table: i8.ReadDatabaseContainer( + $table: i9.ReadDatabaseContainer( $db, ).resultSet('container'), $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, @@ -714,7 +734,8 @@ class $TabTableManager i0.Value orderKey = const i0.Value.absent(), i0.Value url = const i0.Value.absent(), i0.Value title = const i0.Value.absent(), - i0.Value isPrivate = const i0.Value.absent(), + i0.Value tabMode = const i0.Value.absent(), + i0.Value isolationContextId = const i0.Value.absent(), i0.Value isProbablyReaderable = const i0.Value.absent(), i0.Value extractedContentMarkdown = const i0.Value.absent(), @@ -732,7 +753,8 @@ class $TabTableManager orderKey: orderKey, url: url, title: title, - isPrivate: isPrivate, + tabMode: tabMode, + isolationContextId: isolationContextId, isProbablyReaderable: isProbablyReaderable, extractedContentMarkdown: extractedContentMarkdown, extractedContentPlain: extractedContentPlain, @@ -750,7 +772,8 @@ class $TabTableManager required String orderKey, i0.Value url = const i0.Value.absent(), i0.Value title = const i0.Value.absent(), - i0.Value isPrivate = const i0.Value.absent(), + i0.Value tabMode = const i0.Value.absent(), + i0.Value isolationContextId = const i0.Value.absent(), i0.Value isProbablyReaderable = const i0.Value.absent(), i0.Value extractedContentMarkdown = const i0.Value.absent(), @@ -768,7 +791,8 @@ class $TabTableManager orderKey: orderKey, url: url, title: title, - isPrivate: isPrivate, + tabMode: tabMode, + isolationContextId: isolationContextId, isProbablyReaderable: isProbablyReaderable, extractedContentMarkdown: extractedContentMarkdown, extractedContentPlain: extractedContentPlain, @@ -1269,14 +1293,25 @@ class Tab extends i0.Table with i0.TableInfo { requiredDuringInsert: false, $customConstraints: '', ); - late final i0.GeneratedColumn isPrivate = i0.GeneratedColumn( - 'is_private', + late final i0.GeneratedColumnWithTypeConverter + tabMode = i0.GeneratedColumn( + 'tab_mode', aliasedName, - true, - type: i0.DriftSqlType.bool, + false, + type: i0.DriftSqlType.int, requiredDuringInsert: false, - $customConstraints: '', - ); + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const i0.CustomExpression('0'), + ).withConverter(i3.Tab.$convertertabMode); + late final i0.GeneratedColumn isolationContextId = + i0.GeneratedColumn( + 'isolation_context_id', + aliasedName, + true, + type: i0.DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); late final i0.GeneratedColumn isProbablyReaderable = i0.GeneratedColumn( 'is_probably_readerable', @@ -1340,7 +1375,8 @@ class Tab extends i0.Table with i0.TableInfo { orderKey, url, title, - isPrivate, + tabMode, + isolationContextId, isProbablyReaderable, extractedContentMarkdown, extractedContentPlain, @@ -1391,9 +1427,15 @@ class Tab extends i0.Table with i0.TableInfo { i0.DriftSqlType.string, data['${effectivePrefix}title'], ), - isPrivate: attachedDatabase.typeMapping.read( - i0.DriftSqlType.bool, - data['${effectivePrefix}is_private'], + tabMode: i3.Tab.$convertertabMode.fromSql( + attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}tab_mode'], + )!, + ), + isolationContextId: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}isolation_context_id'], ), isProbablyReaderable: attachedDatabase.typeMapping.read( i0.DriftSqlType.bool, @@ -1430,7 +1472,13 @@ class Tab extends i0.Table with i0.TableInfo { static i0.JsonTypeConverter2 $convertersource = const i0.EnumIndexConverter(i6.TabSource.values); static i0.TypeConverter $converterurl = - const i7.UriConverterNullable(); + const i8.UriConverterNullable(); + static i0.JsonTypeConverter2 $convertertabMode = + const i0.EnumIndexConverter(i7.TabModeDbValue.values); + @override + List get customConstraints => const [ + 'CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))', + ]; @override bool get dontWriteConstraints => true; } @@ -1443,7 +1491,8 @@ class TabData extends i0.DataClass implements i0.Insertable { final String orderKey; final Uri? url; final String? title; - final bool? isPrivate; + final i7.TabModeDbValue tabMode; + final String? isolationContextId; final bool? isProbablyReaderable; final String? extractedContentMarkdown; final String? extractedContentPlain; @@ -1458,7 +1507,8 @@ class TabData extends i0.DataClass implements i0.Insertable { required this.orderKey, this.url, this.title, - this.isPrivate, + required this.tabMode, + this.isolationContextId, this.isProbablyReaderable, this.extractedContentMarkdown, this.extractedContentPlain, @@ -1486,8 +1536,13 @@ class TabData extends i0.DataClass implements i0.Insertable { if (!nullToAbsent || title != null) { map['title'] = i0.Variable(title); } - if (!nullToAbsent || isPrivate != null) { - map['is_private'] = i0.Variable(isPrivate); + { + map['tab_mode'] = i0.Variable( + i3.Tab.$convertertabMode.toSql(tabMode), + ); + } + if (!nullToAbsent || isolationContextId != null) { + map['isolation_context_id'] = i0.Variable(isolationContextId); } if (!nullToAbsent || isProbablyReaderable != null) { map['is_probably_readerable'] = i0.Variable(isProbablyReaderable); @@ -1527,7 +1582,12 @@ class TabData extends i0.DataClass implements i0.Insertable { orderKey: serializer.fromJson(json['order_key']), url: serializer.fromJson(json['url']), title: serializer.fromJson(json['title']), - isPrivate: serializer.fromJson(json['is_private']), + tabMode: i3.Tab.$convertertabMode.fromJson( + serializer.fromJson(json['tab_mode']), + ), + isolationContextId: serializer.fromJson( + json['isolation_context_id'], + ), isProbablyReaderable: serializer.fromJson( json['is_probably_readerable'], ), @@ -1557,7 +1617,10 @@ class TabData extends i0.DataClass implements i0.Insertable { 'order_key': serializer.toJson(orderKey), 'url': serializer.toJson(url), 'title': serializer.toJson(title), - 'is_private': serializer.toJson(isPrivate), + 'tab_mode': serializer.toJson( + i3.Tab.$convertertabMode.toJson(tabMode), + ), + 'isolation_context_id': serializer.toJson(isolationContextId), 'is_probably_readerable': serializer.toJson(isProbablyReaderable), 'extracted_content_markdown': serializer.toJson( extractedContentMarkdown, @@ -1579,7 +1642,8 @@ class TabData extends i0.DataClass implements i0.Insertable { String? orderKey, i0.Value url = const i0.Value.absent(), i0.Value title = const i0.Value.absent(), - i0.Value isPrivate = const i0.Value.absent(), + i7.TabModeDbValue? tabMode, + i0.Value isolationContextId = const i0.Value.absent(), i0.Value isProbablyReaderable = const i0.Value.absent(), i0.Value extractedContentMarkdown = const i0.Value.absent(), i0.Value extractedContentPlain = const i0.Value.absent(), @@ -1594,7 +1658,10 @@ class TabData extends i0.DataClass implements i0.Insertable { orderKey: orderKey ?? this.orderKey, url: url.present ? url.value : this.url, title: title.present ? title.value : this.title, - isPrivate: isPrivate.present ? isPrivate.value : this.isPrivate, + tabMode: tabMode ?? this.tabMode, + isolationContextId: isolationContextId.present + ? isolationContextId.value + : this.isolationContextId, isProbablyReaderable: isProbablyReaderable.present ? isProbablyReaderable.value : this.isProbablyReaderable, @@ -1623,7 +1690,10 @@ class TabData extends i0.DataClass implements i0.Insertable { orderKey: data.orderKey.present ? data.orderKey.value : this.orderKey, url: data.url.present ? data.url.value : this.url, title: data.title.present ? data.title.value : this.title, - isPrivate: data.isPrivate.present ? data.isPrivate.value : this.isPrivate, + tabMode: data.tabMode.present ? data.tabMode.value : this.tabMode, + isolationContextId: data.isolationContextId.present + ? data.isolationContextId.value + : this.isolationContextId, isProbablyReaderable: data.isProbablyReaderable.present ? data.isProbablyReaderable.value : this.isProbablyReaderable, @@ -1653,7 +1723,8 @@ class TabData extends i0.DataClass implements i0.Insertable { ..write('orderKey: $orderKey, ') ..write('url: $url, ') ..write('title: $title, ') - ..write('isPrivate: $isPrivate, ') + ..write('tabMode: $tabMode, ') + ..write('isolationContextId: $isolationContextId, ') ..write('isProbablyReaderable: $isProbablyReaderable, ') ..write('extractedContentMarkdown: $extractedContentMarkdown, ') ..write('extractedContentPlain: $extractedContentPlain, ') @@ -1673,7 +1744,8 @@ class TabData extends i0.DataClass implements i0.Insertable { orderKey, url, title, - isPrivate, + tabMode, + isolationContextId, isProbablyReaderable, extractedContentMarkdown, extractedContentPlain, @@ -1692,7 +1764,8 @@ class TabData extends i0.DataClass implements i0.Insertable { other.orderKey == this.orderKey && other.url == this.url && other.title == this.title && - other.isPrivate == this.isPrivate && + other.tabMode == this.tabMode && + other.isolationContextId == this.isolationContextId && other.isProbablyReaderable == this.isProbablyReaderable && other.extractedContentMarkdown == this.extractedContentMarkdown && other.extractedContentPlain == this.extractedContentPlain && @@ -1709,7 +1782,8 @@ class TabCompanion extends i0.UpdateCompanion { final i0.Value orderKey; final i0.Value url; final i0.Value title; - final i0.Value isPrivate; + final i0.Value tabMode; + final i0.Value isolationContextId; final i0.Value isProbablyReaderable; final i0.Value extractedContentMarkdown; final i0.Value extractedContentPlain; @@ -1725,7 +1799,8 @@ class TabCompanion extends i0.UpdateCompanion { this.orderKey = const i0.Value.absent(), this.url = const i0.Value.absent(), this.title = const i0.Value.absent(), - this.isPrivate = const i0.Value.absent(), + this.tabMode = const i0.Value.absent(), + this.isolationContextId = const i0.Value.absent(), this.isProbablyReaderable = const i0.Value.absent(), this.extractedContentMarkdown = const i0.Value.absent(), this.extractedContentPlain = const i0.Value.absent(), @@ -1742,7 +1817,8 @@ class TabCompanion extends i0.UpdateCompanion { required String orderKey, this.url = const i0.Value.absent(), this.title = const i0.Value.absent(), - this.isPrivate = const i0.Value.absent(), + this.tabMode = const i0.Value.absent(), + this.isolationContextId = const i0.Value.absent(), this.isProbablyReaderable = const i0.Value.absent(), this.extractedContentMarkdown = const i0.Value.absent(), this.extractedContentPlain = const i0.Value.absent(), @@ -1762,7 +1838,8 @@ class TabCompanion extends i0.UpdateCompanion { i0.Expression? orderKey, i0.Expression? url, i0.Expression? title, - i0.Expression? isPrivate, + i0.Expression? tabMode, + i0.Expression? isolationContextId, i0.Expression? isProbablyReaderable, i0.Expression? extractedContentMarkdown, i0.Expression? extractedContentPlain, @@ -1779,7 +1856,9 @@ class TabCompanion extends i0.UpdateCompanion { if (orderKey != null) 'order_key': orderKey, if (url != null) 'url': url, if (title != null) 'title': title, - if (isPrivate != null) 'is_private': isPrivate, + if (tabMode != null) 'tab_mode': tabMode, + if (isolationContextId != null) + 'isolation_context_id': isolationContextId, if (isProbablyReaderable != null) 'is_probably_readerable': isProbablyReaderable, if (extractedContentMarkdown != null) @@ -1802,7 +1881,8 @@ class TabCompanion extends i0.UpdateCompanion { i0.Value? orderKey, i0.Value? url, i0.Value? title, - i0.Value? isPrivate, + i0.Value? tabMode, + i0.Value? isolationContextId, i0.Value? isProbablyReaderable, i0.Value? extractedContentMarkdown, i0.Value? extractedContentPlain, @@ -1819,7 +1899,8 @@ class TabCompanion extends i0.UpdateCompanion { orderKey: orderKey ?? this.orderKey, url: url ?? this.url, title: title ?? this.title, - isPrivate: isPrivate ?? this.isPrivate, + tabMode: tabMode ?? this.tabMode, + isolationContextId: isolationContextId ?? this.isolationContextId, isProbablyReaderable: isProbablyReaderable ?? this.isProbablyReaderable, extractedContentMarkdown: extractedContentMarkdown ?? this.extractedContentMarkdown, @@ -1858,8 +1939,15 @@ class TabCompanion extends i0.UpdateCompanion { if (title.present) { map['title'] = i0.Variable(title.value); } - if (isPrivate.present) { - map['is_private'] = i0.Variable(isPrivate.value); + if (tabMode.present) { + map['tab_mode'] = i0.Variable( + i3.Tab.$convertertabMode.toSql(tabMode.value), + ); + } + if (isolationContextId.present) { + map['isolation_context_id'] = i0.Variable( + isolationContextId.value, + ); } if (isProbablyReaderable.present) { map['is_probably_readerable'] = i0.Variable( @@ -1903,7 +1991,8 @@ class TabCompanion extends i0.UpdateCompanion { ..write('orderKey: $orderKey, ') ..write('url: $url, ') ..write('title: $title, ') - ..write('isPrivate: $isPrivate, ') + ..write('tabMode: $tabMode, ') + ..write('isolationContextId: $isolationContextId, ') ..write('isProbablyReaderable: $isProbablyReaderable, ') ..write('extractedContentMarkdown: $extractedContentMarkdown, ') ..write('extractedContentPlain: $extractedContentPlain, ') @@ -2211,7 +2300,7 @@ i0.Trigger get tabAfterUpdate => i0.Trigger( 'tab_after_update', ); -class DefinitionsDrift extends i8.ModularAccessor { +class DefinitionsDrift extends i9.ModularAccessor { DefinitionsDrift(i0.GeneratedDatabase db) : super(db); Future optimizeFtsIndex() { return customInsert( @@ -2284,19 +2373,19 @@ class DefinitionsDrift extends i8.ModularAccessor { ).map((i0.QueryRow row) => row.read('_c0')); } - i0.Selectable queryTabsBasic({ + i0.Selectable queryTabsBasic({ required String query, required int limit, }) { return customSelect( - 'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight) SELECT t.id, t.container_id, t.is_private, t.title, CAST(t.url AS TEXT) AS url, t.url AS clean_url, bm25(tab_fts, weights.title_weight, weights.url_weight) AS weighted_rank FROM tab_fts AS fts INNER JOIN tab AS t ON t."rowid" = fts."rowid" CROSS JOIN weights WHERE fts.title LIKE ?1 OR fts.url LIKE ?1 ORDER BY weighted_rank ASC, t.timestamp DESC LIMIT ?2', + 'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight) SELECT t.id, t.container_id, t.tab_mode, t.title, CAST(t.url AS TEXT) AS url, t.url AS clean_url, bm25(tab_fts, weights.title_weight, weights.url_weight) AS weighted_rank FROM tab_fts AS fts INNER JOIN tab AS t ON t."rowid" = fts."rowid" CROSS JOIN weights WHERE fts.title LIKE ?1 OR fts.url LIKE ?1 ORDER BY weighted_rank ASC, t.timestamp DESC LIMIT ?2', variables: [i0.Variable(query), i0.Variable(limit)], readsFrom: {tab, tabFts}, ).map( - (i0.QueryRow row) => i9.TabQueryResult( + (i0.QueryRow row) => i10.TabQueryResult( id: row.read('id'), containerId: row.readNullable('container_id'), - isPrivate: row.readNullable('is_private'), + tabMode: i3.Tab.$convertertabMode.fromSql(row.read('tab_mode')), title: row.readNullable('title'), url: row.readNullable('url'), cleanUrl: i3.Tab.$converterurl.fromSql( @@ -2307,7 +2396,7 @@ class DefinitionsDrift extends i8.ModularAccessor { ); } - i0.Selectable queryTabsFullContent({ + i0.Selectable queryTabsFullContent({ required String beforeMatch, required String afterMatch, required String ellipsis, @@ -2316,7 +2405,7 @@ class DefinitionsDrift extends i8.ModularAccessor { required int limit, }) { return customSelect( - 'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight, 3.0 AS extracted_weight, 1.0 AS full_weight) SELECT t.id, t.container_id, t.is_private, highlight(tab_fts, 0, ?1, ?2) AS title, highlight(tab_fts, 1, ?1, ?2) AS url, snippet(tab_fts, 2, ?1, ?2, ?3, ?4) AS extracted_content, snippet(tab_fts, 3, ?1, ?2, ?3, ?4) AS full_content, t.url AS clean_url,(bm25(tab_fts, weights.title_weight, weights.url_weight, weights.extracted_weight, weights.full_weight))AS weighted_rank FROM tab_fts(?5)AS fts INNER JOIN tab AS t ON t."rowid" = fts."rowid" CROSS JOIN weights ORDER BY weighted_rank ASC, t.timestamp DESC LIMIT ?6', + 'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight, 3.0 AS extracted_weight, 1.0 AS full_weight) SELECT t.id, t.container_id, t.tab_mode, highlight(tab_fts, 0, ?1, ?2) AS title, highlight(tab_fts, 1, ?1, ?2) AS url, snippet(tab_fts, 2, ?1, ?2, ?3, ?4) AS extracted_content, snippet(tab_fts, 3, ?1, ?2, ?3, ?4) AS full_content, t.url AS clean_url,(bm25(tab_fts, weights.title_weight, weights.url_weight, weights.extracted_weight, weights.full_weight))AS weighted_rank FROM tab_fts(?5)AS fts INNER JOIN tab AS t ON t."rowid" = fts."rowid" CROSS JOIN weights ORDER BY weighted_rank ASC, t.timestamp DESC LIMIT ?6', variables: [ i0.Variable(beforeMatch), i0.Variable(afterMatch), @@ -2327,10 +2416,10 @@ class DefinitionsDrift extends i8.ModularAccessor { ], readsFrom: {tab, tabFts}, ).map( - (i0.QueryRow row) => i9.TabQueryResult( + (i0.QueryRow row) => i10.TabQueryResult( id: row.read('id'), containerId: row.readNullable('container_id'), - isPrivate: row.readNullable('is_private'), + tabMode: i3.Tab.$convertertabMode.fromSql(row.read('tab_mode')), title: row.readNullable('title'), url: row.readNullable('url'), cleanUrl: i3.Tab.$converterurl.fromSql( @@ -2443,13 +2532,13 @@ class DefinitionsDrift extends i8.ModularAccessor { ).map((i0.QueryRow row) => row.read('id')); } - i0.Selectable allAssignedSites() { + i0.Selectable allAssignedSites() { return customSelect( 'SELECT container.id, COALESCE(container.metadata ->> \'\$.contextualIdentity\', \'general\') AS contextualIdentity, value AS assigned_site FROM container CROSS JOIN json_each(container.metadata, \'\$.assignedSites\')WHERE value IS NOT NULL', variables: [], readsFrom: {container}, ).map( - (i0.QueryRow row) => i10.SiteAssignment( + (i0.QueryRow row) => i11.SiteAssignment( id: row.read('id'), contextualIdentity: row.read('contextualIdentity'), assignedSite: row.readNullable('assigned_site'), @@ -2465,14 +2554,46 @@ class DefinitionsDrift extends i8.ModularAccessor { ).map((i0.QueryRow row) => row.readNullable('contextual_identity')); } - i3.TabFts get tabFts => i8.ReadDatabaseContainer( + i0.Selectable tabsInIsolationGroup({String? contextId}) { + return customSelect( + 'SELECT COUNT(*) AS count FROM tab WHERE isolation_context_id = ?1', + variables: [i0.Variable(contextId)], + readsFrom: {tab}, + ).map((i0.QueryRow row) => row.read('count')); + } + + i0.Selectable allIsolationContextIds() { + return customSelect( + 'SELECT DISTINCT isolation_context_id FROM tab WHERE isolation_context_id IS NOT NULL', + variables: [], + readsFrom: {tab}, + ).map( + (i0.QueryRow row) => row.readNullable('isolation_context_id'), + ); + } + + i0.Selectable + isolatedContextContainerPairs() { + return customSelect( + 'SELECT DISTINCT t.isolation_context_id, t.container_id FROM tab AS t WHERE t.tab_mode = 2 AND t.isolation_context_id IS NOT NULL AND t.container_id IS NOT NULL', + variables: [], + readsFrom: {tab}, + ).map( + (i0.QueryRow row) => IsolatedContextContainerPairsResult( + isolationContextId: row.readNullable('isolation_context_id'), + containerId: row.readNullable('container_id'), + ), + ); + } + + i3.TabFts get tabFts => i9.ReadDatabaseContainer( attachedDatabase, ).resultSet('tab_fts'); - i3.Container get container => i8.ReadDatabaseContainer( + i3.Container get container => i9.ReadDatabaseContainer( attachedDatabase, ).resultSet('container'); i3.Tab get tab => - i8.ReadDatabaseContainer(attachedDatabase).resultSet('tab'); + i9.ReadDatabaseContainer(attachedDatabase).resultSet('tab'); } class TabTreesResult { @@ -2493,3 +2614,12 @@ class UnorderedTabDescendantsResult { final String? parentId; UnorderedTabDescendantsResult({required this.id, this.parentId}); } + +class IsolatedContextContainerPairsResult { + final String? isolationContextId; + final String? containerId; + IsolatedContextContainerPairsResult({ + this.isolationContextId, + this.containerId, + }); +} diff --git a/app/lib/features/geckoview/features/tabs/data/entities/isolation_context.dart b/app/lib/features/geckoview/features/tabs/data/entities/isolation_context.dart new file mode 100644 index 00000000..5b3a3134 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/data/entities/isolation_context.dart @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:weblibre/core/uuid.dart'; + +const _isolatedPrefix = 'iso1_'; + +/// Generates a new unique isolation context ID in the format `iso1_`. +String newIsolatedContextId() { + final id = uuid.v4(); + return '$_isolatedPrefix$id'; +} + +/// Returns `true` if the given context ID identifies an isolated tab context. +bool isIsolatedContextId(String? contextId) { + if (contextId == null) return false; + return contextId.startsWith(_isolatedPrefix); +} diff --git a/app/lib/features/geckoview/features/tabs/data/entities/tab_mode.dart b/app/lib/features/geckoview/features/tabs/data/entities/tab_mode.dart new file mode 100644 index 00000000..ea42c4d5 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/data/entities/tab_mode.dart @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolation_context.dart'; + +/// Persisted tab privacy/isolation mode. +/// +/// Values map to integer values stored in the `tab_mode` column: +/// - 0 = regular +/// - 1 = private +/// - 2 = isolated +enum TabModeDbValue { regular, private, isolated } + +sealed class TabMode { + static const TabMode regular = RegularTabMode(); + static const TabMode private = PrivateTabMode(); + + const TabMode(); + + factory TabMode.isolated(String isolationContextId) => + IsolatedTabMode(isolationContextId); + + factory TabMode.newIsolated() => IsolatedTabMode(newIsolatedContextId()); + + factory TabMode.fromTabType(TabType tabType) => switch (tabType) { + TabType.private => TabMode.private, + TabType.isolated => TabMode.newIsolated(), + _ => TabMode.regular, + }; + + TabModeDbValue toDbValue() => switch (this) { + RegularTabMode() => TabModeDbValue.regular, + PrivateTabMode() => TabModeDbValue.private, + IsolatedTabMode() => TabModeDbValue.isolated, + }; + + String? get isolationContextId => switch (this) { + IsolatedTabMode(:final isolationContextId) => isolationContextId, + _ => null, + }; + + TabType toTabType() => switch (this) { + RegularTabMode() => TabType.regular, + PrivateTabMode() => TabType.private, + IsolatedTabMode() => TabType.isolated, + }; + + factory TabMode.fromDbValue( + TabModeDbValue dbValue, { + required String? isolationContextId, + }) { + return switch (dbValue) { + TabModeDbValue.regular => regular, + TabModeDbValue.private => private, + TabModeDbValue.isolated when isolationContextId != null => + TabMode.isolated(isolationContextId), + TabModeDbValue.isolated => regular, + }; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is TabMode && + other.toDbValue() == toDbValue() && + other.isolationContextId == isolationContextId; + } + + @override + int get hashCode => Object.hash(toDbValue(), isolationContextId); +} + +final class RegularTabMode extends TabMode { + const RegularTabMode(); +} + +final class PrivateTabMode extends TabMode { + const PrivateTabMode(); +} + +final class IsolatedTabMode extends TabMode { + @override + final String isolationContextId; + + const IsolatedTabMode(this.isolationContextId); +} diff --git a/app/lib/features/geckoview/features/tabs/data/models/tab_query_result.dart b/app/lib/features/geckoview/features/tabs/data/models/tab_query_result.dart index a44fb214..c1cabf88 100644 --- a/app/lib/features/geckoview/features/tabs/data/models/tab_query_result.dart +++ b/app/lib/features/geckoview/features/tabs/data/models/tab_query_result.dart @@ -18,11 +18,12 @@ * along with this program. If not, see . */ import 'package:fast_equatable/fast_equatable.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; class TabQueryResult with FastEquatable { final String id; final String? containerId; - final bool? isPrivate; + final TabModeDbValue tabMode; final String? title; final Uri? cleanUrl; @@ -36,7 +37,7 @@ class TabQueryResult with FastEquatable { TabQueryResult({ required this.id, required this.containerId, - required this.isPrivate, + required this.tabMode, required this.title, required this.url, required this.cleanUrl, @@ -49,7 +50,7 @@ class TabQueryResult with FastEquatable { List get hashParameters => [ id, containerId, - isPrivate, + tabMode, title, cleanUrl, url, diff --git a/app/lib/features/geckoview/features/tabs/domain/providers.dart b/app/lib/features/geckoview/features/tabs/domain/providers.dart index f4cc1ebe..3b9f1ebc 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers.dart @@ -155,6 +155,25 @@ Stream> watchAllAssignedSites(Ref ref) { return db.containerDao.allAssignedSites().watch(); } +/// Watches distinct (isolationContextId, containerId) pairs for isolated tabs +/// assigned to containers. Used by ProxySettingsReplication to manage proxy +/// aliases for isolated contexts. +/// +/// Returns a map from isolation context ID to the set of container IDs it +/// appears in. An isolation context needs a proxy alias if ANY of its +/// associated containers has useProxy enabled. +@Riverpod(keepAlive: true) +Stream>> watchIsolatedContextContainerMap(Ref ref) { + final db = ref.watch(tabDatabaseProvider); + return db.tabDao.isolatedContextContainerPairs().watch().map((pairs) { + final map = >{}; + for (final p in pairs) { + map.putIfAbsent(p.isolationContextId!, () => {}).add(p.containerId!); + } + return map; + }); +} + @Riverpod() Stream watchIsCurrentSiteAssignedToContainer(Ref ref) { final currentUri = ref.watch( diff --git a/app/lib/features/geckoview/features/tabs/domain/providers.g.dart b/app/lib/features/geckoview/features/tabs/domain/providers.g.dart index 558ea964..9d1e8949 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers.g.dart @@ -871,6 +871,72 @@ final class WatchAllAssignedSitesProvider String _$watchAllAssignedSitesHash() => r'5f658b5733ee20192eb86d3aeb79aa9678dafba8'; +/// Watches distinct (isolationContextId, containerId) pairs for isolated tabs +/// assigned to containers. Used by ProxySettingsReplication to manage proxy +/// aliases for isolated contexts. +/// +/// Returns a map from isolation context ID to the set of container IDs it +/// appears in. An isolation context needs a proxy alias if ANY of its +/// associated containers has useProxy enabled. + +@ProviderFor(watchIsolatedContextContainerMap) +final watchIsolatedContextContainerMapProvider = + WatchIsolatedContextContainerMapProvider._(); + +/// Watches distinct (isolationContextId, containerId) pairs for isolated tabs +/// assigned to containers. Used by ProxySettingsReplication to manage proxy +/// aliases for isolated contexts. +/// +/// Returns a map from isolation context ID to the set of container IDs it +/// appears in. An isolation context needs a proxy alias if ANY of its +/// associated containers has useProxy enabled. + +final class WatchIsolatedContextContainerMapProvider + extends + $FunctionalProvider< + AsyncValue>>, + Map>, + Stream>> + > + with + $FutureModifier>>, + $StreamProvider>> { + /// Watches distinct (isolationContextId, containerId) pairs for isolated tabs + /// assigned to containers. Used by ProxySettingsReplication to manage proxy + /// aliases for isolated contexts. + /// + /// Returns a map from isolation context ID to the set of container IDs it + /// appears in. An isolation context needs a proxy alias if ANY of its + /// associated containers has useProxy enabled. + WatchIsolatedContextContainerMapProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'watchIsolatedContextContainerMapProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$watchIsolatedContextContainerMapHash(); + + @$internal + @override + $StreamProviderElement>> $createElement( + $ProviderPointer pointer, + ) => $StreamProviderElement(pointer); + + @override + Stream>> create(Ref ref) { + return watchIsolatedContextContainerMap(ref); + } +} + +String _$watchIsolatedContextContainerMapHash() => + r'6275a6c508777c306e2ababca83e2792d6d5efe1'; + @ProviderFor(watchIsCurrentSiteAssignedToContainer) final watchIsCurrentSiteAssignedToContainerProvider = WatchIsCurrentSiteAssignedToContainerProvider._(); diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart index 41f02c7f..0d000bb8 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart @@ -23,6 +23,7 @@ import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; @@ -36,6 +37,16 @@ class TabDataRepository extends _$TabDataRepository { bool closeOldTab = true, }) async { final selectedTabId = ref.read(selectedTabProvider); + final tabState = ref.read(tabStatesProvider)[tabId]; + + // Isolated tabs: always do DB-only assignment (never recreate/recontext) + if (tabState != null && tabState.tabMode is IsolatedTabMode) { + await ref + .read(tabDatabaseProvider) + .tabDao + .assignContainer(tabId, containerId: targetContainer.id); + return; + } final currentContainerData = await getTabContainerData(tabId); @@ -46,7 +57,6 @@ class TabDataRepository extends _$TabDataRepository { .tabDao .assignContainer(tabId, containerId: targetContainer.id); } else { - final tabState = ref.read(tabStatesProvider)[tabId]; if (tabState != null) { if (closeOldTab) { await ref.read(tabRepositoryProvider.notifier).closeTab(tabId); @@ -56,7 +66,7 @@ class TabDataRepository extends _$TabDataRepository { .read(tabRepositoryProvider.notifier) .addTab( url: tabState.url, - private: tabState.isPrivate, + tabMode: tabState.tabMode, containerSelection: TabContainerSelection.specific( targetContainer, ), @@ -69,6 +79,16 @@ class TabDataRepository extends _$TabDataRepository { Future unassignContainer(String tabId) async { final selectedTabId = ref.read(selectedTabProvider); + final tabState = ref.read(tabStatesProvider)[tabId]; + + // Isolated tabs: always do DB-only unassignment (never recreate/recontext) + if (tabState != null && tabState.tabMode is IsolatedTabMode) { + await ref + .read(tabDatabaseProvider) + .tabDao + .assignContainer(tabId, containerId: null); + return; + } final currentContainerData = await getTabContainerData(tabId); @@ -78,7 +98,6 @@ class TabDataRepository extends _$TabDataRepository { .tabDao .assignContainer(tabId, containerId: null); } else { - final tabState = ref.read(tabStatesProvider)[tabId]; if (tabState != null) { await ref.read(tabRepositoryProvider.notifier).closeTab(tabId); @@ -86,7 +105,7 @@ class TabDataRepository extends _$TabDataRepository { .read(tabRepositoryProvider.notifier) .addTab( url: tabState.url, - private: tabState.isPrivate, + tabMode: tabState.tabMode, containerSelection: const TabContainerSelection.unassigned(), // parentId defaults to null - breaks parent chain when removing contextual identity selectTab: selectedTabId == tabState.id, @@ -105,6 +124,7 @@ class TabDataRepository extends _$TabDataRepository { Future closeAllTabs({ bool includeRegular = true, bool includePrivate = true, + bool includeIsolated = true, }) async { final tabIds = await ref .read(tabDatabaseProvider) @@ -112,6 +132,7 @@ class TabDataRepository extends _$TabDataRepository { .getAllTabIds( includeRegular: includeRegular, includePrivate: includePrivate, + includeIsolated: includeIsolated, ) .get(); @@ -126,6 +147,7 @@ class TabDataRepository extends _$TabDataRepository { String? containerId, { bool includeRegular = true, bool includePrivate = true, + bool includeIsolated = true, }) async { final tabIds = await ref .read(tabDatabaseProvider) @@ -134,6 +156,7 @@ class TabDataRepository extends _$TabDataRepository { containerId, includeRegular: includeRegular, includePrivate: includePrivate, + includeIsolated: includeIsolated, ) .get(); diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart index 2ea17f4f..540d6b7f 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart @@ -41,7 +41,7 @@ final class TabDataRepositoryProvider } } -String _$tabDataRepositoryHash() => r'c1bdfbb576379a7d2ac7b2fe310dbec090e7fd4d'; +String _$tabDataRepositoryHash() => r'41ca88333684db71f809e35874b0e5bdddde0f60'; abstract class _$TabDataRepository extends $Notifier { void build(); diff --git a/app/lib/features/onboarding/presentation/onboarding.dart b/app/lib/features/onboarding/presentation/onboarding.dart index 0e479947..2a7d1184 100644 --- a/app/lib/features/onboarding/presentation/onboarding.dart +++ b/app/lib/features/onboarding/presentation/onboarding.dart @@ -25,6 +25,7 @@ import 'package:weblibre/core/providers/defaults.dart'; import 'package:weblibre/core/providers/router.dart'; import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/onboarding/presentation/pages/abstract/i_form_page.dart'; import 'package:weblibre/features/onboarding/presentation/pages/ai_configuration.dart'; import 'package:weblibre/features/onboarding/presentation/pages/default_search.dart'; @@ -147,7 +148,7 @@ class OnboardingScreen extends HookConsumerWidget { .read(tabRepositoryProvider.notifier) .addTab( url: ref.read(docsUriProvider), - private: false, + tabMode: TabMode.regular, containerSelection: const TabContainerSelection.unassigned(), selectTab: true, diff --git a/app/lib/features/settings/presentation/screens/tabs_behavior_settings.dart b/app/lib/features/settings/presentation/screens/tabs_behavior_settings.dart index ed2f22f4..efcb4509 100644 --- a/app/lib/features/settings/presentation/screens/tabs_behavior_settings.dart +++ b/app/lib/features/settings/presentation/screens/tabs_behavior_settings.dart @@ -129,17 +129,27 @@ class _NewTabDefaultSection extends HookConsumerWidget { Center( child: SegmentedButton( showSelectedIcon: false, - segments: const [ - ButtonSegment( + segments: [ + const ButtonSegment( value: TabType.regular, label: Text('Regular'), icon: Icon(MdiIcons.tab), ), - ButtonSegment( + const ButtonSegment( value: TabType.private, label: Text('Private'), icon: Icon(WebLibreIcons.privateTab), ), + ButtonSegment( + value: TabType.isolated, + label: const Text('Isolated'), + icon: Icon( + MdiIcons.shieldLock, + color: defaultCreateTabType == TabType.isolated + ? null + : appColors.isolatedTabTeal, + ), + ), ], selected: {defaultCreateTabType}, onSelectionChanged: (value) async { @@ -156,6 +166,9 @@ class _NewTabDefaultSection extends HookConsumerWidget { selectedBackgroundColor: appColors.privateSelectionOverlay, ), TabType.child => null, + TabType.isolated => SegmentedButton.styleFrom( + selectedBackgroundColor: appColors.isolatedSelectionOverlay, + ), }, ), ), diff --git a/app/lib/features/sync/domain/repositories/sync.dart b/app/lib/features/sync/domain/repositories/sync.dart index d6928107..85cdd560 100644 --- a/app/lib/features/sync/domain/repositories/sync.dart +++ b/app/lib/features/sync/domain/repositories/sync.dart @@ -7,6 +7,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/core/logger.dart'; import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart'; import 'package:weblibre/features/sync/domain/entities/synced_tab_item.dart'; @@ -281,7 +282,7 @@ class SyncRepository extends _$SyncRepository { .addTab( url: uri, selectTab: true, - private: false, + tabMode: TabMode.regular, containerSelection: assignedContainer == null ? const TabContainerSelection.unassigned() : TabContainerSelection.specific(assignedContainer), diff --git a/app/lib/features/sync/domain/repositories/sync.g.dart b/app/lib/features/sync/domain/repositories/sync.g.dart index 3ac6be6c..b3627933 100644 --- a/app/lib/features/sync/domain/repositories/sync.g.dart +++ b/app/lib/features/sync/domain/repositories/sync.g.dart @@ -336,7 +336,7 @@ final class SyncRepositoryProvider SyncRepository create() => SyncRepository(); } -String _$syncRepositoryHash() => r'5bfe6deacfea9b14981a0b9769857f23b7dd478c'; +String _$syncRepositoryHash() => r'c2765421a6cea9620e752aa9cbb37f79baf5b1c3'; abstract class _$SyncRepository extends $AsyncNotifier { FutureOr build(); diff --git a/app/lib/features/user/data/database/database.steps.dart b/app/lib/features/user/data/database/database.steps.dart index 4a4880a9..befb380a 100644 --- a/app/lib/features/user/data/database/database.steps.dart +++ b/app/lib/features/user/data/database/database.steps.dart @@ -2,9 +2,10 @@ import 'package:drift/internal/versioned_schema.dart' as i0; import 'package:drift/drift.dart' as i1; import 'dart:typed_data' as i2; -import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import +import 'package:drift/drift.dart'; // GENERATED BY drift_dev, DO NOT MODIFY. -// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// final class Schema2 extends i0.VersionedSchema { Schema2({required super.database}) : super(version: 2); @override diff --git a/app/lib/features/user/data/models/general_settings.g.dart b/app/lib/features/user/data/models/general_settings.g.dart index 095d50a8..a69354c5 100644 --- a/app/lib/features/user/data/models/general_settings.g.dart +++ b/app/lib/features/user/data/models/general_settings.g.dart @@ -803,6 +803,7 @@ const _$TabTypeEnumMap = { TabType.regular: 'regular', TabType.private: 'private', TabType.child: 'child', + TabType.isolated: 'isolated', }; const _$TabIntentOpenSettingEnumMap = { diff --git a/app/lib/features/web_feed/presentation/screens/feed_article.dart b/app/lib/features/web_feed/presentation/screens/feed_article.dart index 4cf3c5b1..37e89a85 100644 --- a/app/lib/features/web_feed/presentation/screens/feed_article.dart +++ b/app/lib/features/web_feed/presentation/screens/feed_article.dart @@ -27,6 +27,7 @@ import 'package:weblibre/core/providers/format.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/features/web_feed/data/models/feed_link.dart'; import 'package:weblibre/features/web_feed/domain/providers.dart'; @@ -195,17 +196,17 @@ class FeedArticleScreen extends HookConsumerWidget { if (articleLink != null) IconButton( onPressed: () async { - final isPrivate = - ref - .read(generalSettingsWithDefaultsProvider) - .defaultCreateTabType == - TabType.private; + final tabMode = TabMode.fromTabType( + ref + .read(generalSettingsWithDefaultsProvider) + .defaultCreateTabType, + ); await ref .read(tabRepositoryProvider.notifier) .addTab( url: articleLink.uri, - private: isPrivate, + tabMode: tabMode, containerSelection: const TabContainerSelection.unassigned(), selectTab: true, @@ -245,17 +246,17 @@ class FeedArticleScreen extends HookConsumerWidget { onTapLink: (text, href, title) async { if (href.mapNotNull(Uri.tryParse) case final Uri url) { - final isPrivate = - ref - .read(generalSettingsWithDefaultsProvider) - .defaultCreateTabType == - TabType.private; + final tabMode = TabMode.fromTabType( + ref + .read(generalSettingsWithDefaultsProvider) + .defaultCreateTabType, + ); await ref .read(tabRepositoryProvider.notifier) .addTab( url: url, - private: isPrivate, + tabMode: tabMode, selectTab: true, ); diff --git a/app/lib/presentation/controllers/website_title.dart b/app/lib/presentation/controllers/website_title.dart index 5a826ee0..d03a046b 100644 --- a/app/lib/presentation/controllers/website_title.dart +++ b/app/lib/presentation/controllers/website_title.dart @@ -26,6 +26,7 @@ import 'package:weblibre/extensions/ref_cache.dart'; import 'package:weblibre/extensions/uri.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/tor/domain/services/tor_proxy.dart'; import 'package:weblibre/features/user/data/models/tor_settings.dart'; @@ -88,9 +89,10 @@ Future pageInfo( final torSettings = ref.read(torSettingsWithDefaultsProvider); if (containerData?.metadata.useProxy == true || - (tabState.isPrivate == false && + (tabState.tabMode is! PrivateTabMode && torSettings.proxyRegularTabsMode == TorRegularTabProxyMode.all) || - (tabState.isPrivate == true && torSettings.proxyPrivateTabsTor)) { + (tabState.tabMode is PrivateTabMode && + torSettings.proxyPrivateTabsTor)) { proxyPort = await ref.read( torProxyServiceProvider.selectAsync((value) => value.socksPort), ); diff --git a/app/lib/presentation/controllers/website_title.g.dart b/app/lib/presentation/controllers/website_title.g.dart index 8a7682eb..86fbff03 100644 --- a/app/lib/presentation/controllers/website_title.g.dart +++ b/app/lib/presentation/controllers/website_title.g.dart @@ -162,7 +162,7 @@ final class PageInfoProvider } } -String _$pageInfoHash() => r'19116a520df214240254f95989818c1ac3bde6f6'; +String _$pageInfoHash() => r'c8b7a44b675742436999941b526e9b268ebd139e'; final class PageInfoFamily extends $Family with diff --git a/app/lib/utils/ui_helper.dart b/app/lib/utils/ui_helper.dart index 57737f2b..e7b60ce3 100644 --- a/app/lib/utils/ui_helper.dart +++ b/app/lib/utils/ui_helper.dart @@ -236,6 +236,37 @@ void showTabUndoClose( ScaffoldMessenger.of(context).showSnackBar(snackBar); } +/// Shows a confirmation dialog before closing isolated tabs whose data +/// will be permanently cleared. Returns `true` if the user confirms. +Future confirmIsolatedTabClose( + BuildContext context, { + int groupCount = 1, +}) async { + final message = groupCount == 1 + ? 'This will permanently clear all browsing data for this isolated session.' + : 'This will permanently clear browsing data for $groupCount isolated sessions.'; + + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Close isolated tabs?'), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Close'), + ), + ], + ), + ); + + return result ?? false; +} + void showDismissOverrideMessage( BuildContext context, VoidCallback onDismiss, { diff --git a/app/test/drift/bangs/generated/schema.dart b/app/test/drift/bangs/generated/schema.dart index 22131b11..0c4b8fa4 100644 --- a/app/test/drift/bangs/generated/schema.dart +++ b/app/test/drift/bangs/generated/schema.dart @@ -1,6 +1,7 @@ // dart format width=80 -// GENERATED CODE, DO NOT EDIT BY HAND. -// ignore_for_file: type=lint +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// import 'package:drift/drift.dart'; import 'package:drift/internal/migrations.dart'; import 'schema_v1.dart' as v1; diff --git a/app/test/drift/bangs/generated/schema_v1.dart b/app/test/drift/bangs/generated/schema_v1.dart index 878b9379..ca91c43b 100644 --- a/app/test/drift/bangs/generated/schema_v1.dart +++ b/app/test/drift/bangs/generated/schema_v1.dart @@ -1,6 +1,7 @@ // dart format width=80 -// GENERATED CODE, DO NOT EDIT BY HAND. -// ignore_for_file: type=lint +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// import 'package:drift/drift.dart'; class Bang extends Table with TableInfo { diff --git a/app/test/drift/bangs/generated/schema_v2.dart b/app/test/drift/bangs/generated/schema_v2.dart index a260405d..098f5880 100644 --- a/app/test/drift/bangs/generated/schema_v2.dart +++ b/app/test/drift/bangs/generated/schema_v2.dart @@ -1,6 +1,7 @@ // dart format width=80 -// GENERATED CODE, DO NOT EDIT BY HAND. -// ignore_for_file: type=lint +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// import 'package:drift/drift.dart'; class Bang extends Table with TableInfo { diff --git a/app/test/drift/bangs/generated/schema_v3.dart b/app/test/drift/bangs/generated/schema_v3.dart index a46d3a62..69370a16 100644 --- a/app/test/drift/bangs/generated/schema_v3.dart +++ b/app/test/drift/bangs/generated/schema_v3.dart @@ -1,6 +1,7 @@ // dart format width=80 -// GENERATED CODE, DO NOT EDIT BY HAND. -// ignore_for_file: type=lint +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// import 'package:drift/drift.dart'; class Bang extends Table with TableInfo { diff --git a/app/test/drift/bangs/generated/schema_v4.dart b/app/test/drift/bangs/generated/schema_v4.dart index 69d22f13..3ba2fde8 100644 --- a/app/test/drift/bangs/generated/schema_v4.dart +++ b/app/test/drift/bangs/generated/schema_v4.dart @@ -1,6 +1,7 @@ // dart format width=80 -// GENERATED CODE, DO NOT EDIT BY HAND. -// ignore_for_file: type=lint +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// import 'package:drift/drift.dart'; class Bang extends Table with TableInfo { diff --git a/app/test/drift/tabs/generated/schema.dart b/app/test/drift/tabs/generated/schema.dart index d473ee66..35ab6136 100644 --- a/app/test/drift/tabs/generated/schema.dart +++ b/app/test/drift/tabs/generated/schema.dart @@ -1,12 +1,14 @@ // dart format width=80 -// GENERATED CODE, DO NOT EDIT BY HAND. -// ignore_for_file: type=lint +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// import 'package:drift/drift.dart'; import 'package:drift/internal/migrations.dart'; import 'schema_v2.dart' as v2; import 'schema_v3.dart' as v3; import 'schema_v4.dart' as v4; import 'schema_v5.dart' as v5; +import 'schema_v6.dart' as v6; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -20,10 +22,12 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v4.DatabaseAtV4(db); case 5: return v5.DatabaseAtV5(db); + case 6: + return v6.DatabaseAtV6(db); default: throw MissingSchemaException(version, versions); } } - static const versions = const [2, 3, 4, 5]; + static const versions = const [2, 3, 4, 5, 6]; } diff --git a/app/test/drift/tabs/generated/schema_v2.dart b/app/test/drift/tabs/generated/schema_v2.dart index 341bab4a..310bf194 100644 --- a/app/test/drift/tabs/generated/schema_v2.dart +++ b/app/test/drift/tabs/generated/schema_v2.dart @@ -1,6 +1,7 @@ // dart format width=80 -// GENERATED CODE, DO NOT EDIT BY HAND. -// ignore_for_file: type=lint +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// import 'package:drift/drift.dart'; class Container extends Table with TableInfo { diff --git a/app/test/drift/tabs/generated/schema_v3.dart b/app/test/drift/tabs/generated/schema_v3.dart index 377a09a3..a35aa710 100644 --- a/app/test/drift/tabs/generated/schema_v3.dart +++ b/app/test/drift/tabs/generated/schema_v3.dart @@ -1,6 +1,7 @@ // dart format width=80 -// GENERATED CODE, DO NOT EDIT BY HAND. -// ignore_for_file: type=lint +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// import 'package:drift/drift.dart'; class Container extends Table with TableInfo { diff --git a/app/test/drift/tabs/generated/schema_v4.dart b/app/test/drift/tabs/generated/schema_v4.dart index 7c37ed86..82fd2d0b 100644 --- a/app/test/drift/tabs/generated/schema_v4.dart +++ b/app/test/drift/tabs/generated/schema_v4.dart @@ -1,6 +1,7 @@ // dart format width=80 -// GENERATED CODE, DO NOT EDIT BY HAND. -// ignore_for_file: type=lint +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// import 'package:drift/drift.dart'; class Container extends Table with TableInfo { diff --git a/app/test/drift/tabs/generated/schema_v5.dart b/app/test/drift/tabs/generated/schema_v5.dart index 73274dfc..b7835d1e 100644 --- a/app/test/drift/tabs/generated/schema_v5.dart +++ b/app/test/drift/tabs/generated/schema_v5.dart @@ -1,6 +1,7 @@ // dart format width=80 -// GENERATED CODE, DO NOT EDIT BY HAND. -// ignore_for_file: type=lint +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// import 'package:drift/drift.dart'; class Container extends Table with TableInfo { diff --git a/app/test/drift/tabs/generated/schema_v6.dart b/app/test/drift/tabs/generated/schema_v6.dart new file mode 100644 index 00000000..bb827892 --- /dev/null +++ b/app/test/drift/tabs/generated/schema_v6.dart @@ -0,0 +1,1356 @@ +// dart format width=80 +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; + +class Container extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Container(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn color = GeneratedColumn( + 'color', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn metadata = GeneratedColumn( + 'metadata', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [id, name, color, metadata]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'container'; + @override + Set get $primaryKey => {id}; + @override + ContainerData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ContainerData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + ), + color: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}color'], + )!, + metadata: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}metadata'], + ), + ); + } + + @override + Container createAlias(String alias) { + return Container(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class ContainerData extends DataClass implements Insertable { + final String id; + final String? name; + final int color; + final String? metadata; + const ContainerData({ + required this.id, + this.name, + required this.color, + this.metadata, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + if (!nullToAbsent || name != null) { + map['name'] = Variable(name); + } + map['color'] = Variable(color); + if (!nullToAbsent || metadata != null) { + map['metadata'] = Variable(metadata); + } + return map; + } + + factory ContainerData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ContainerData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + color: serializer.fromJson(json['color']), + metadata: serializer.fromJson(json['metadata']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'color': serializer.toJson(color), + 'metadata': serializer.toJson(metadata), + }; + } + + ContainerData copyWith({ + String? id, + Value name = const Value.absent(), + int? color, + Value metadata = const Value.absent(), + }) => ContainerData( + id: id ?? this.id, + name: name.present ? name.value : this.name, + color: color ?? this.color, + metadata: metadata.present ? metadata.value : this.metadata, + ); + ContainerData copyWithCompanion(ContainerCompanion data) { + return ContainerData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + color: data.color.present ? data.color.value : this.color, + metadata: data.metadata.present ? data.metadata.value : this.metadata, + ); + } + + @override + String toString() { + return (StringBuffer('ContainerData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('color: $color, ') + ..write('metadata: $metadata') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, name, color, metadata); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ContainerData && + other.id == this.id && + other.name == this.name && + other.color == this.color && + other.metadata == this.metadata); +} + +class ContainerCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value color; + final Value metadata; + final Value rowid; + const ContainerCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.color = const Value.absent(), + this.metadata = const Value.absent(), + this.rowid = const Value.absent(), + }); + ContainerCompanion.insert({ + required String id, + this.name = const Value.absent(), + required int color, + this.metadata = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + color = Value(color); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? color, + Expression? metadata, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (color != null) 'color': color, + if (metadata != null) 'metadata': metadata, + if (rowid != null) 'rowid': rowid, + }); + } + + ContainerCompanion copyWith({ + Value? id, + Value? name, + Value? color, + Value? metadata, + Value? rowid, + }) { + return ContainerCompanion( + id: id ?? this.id, + name: name ?? this.name, + color: color ?? this.color, + metadata: metadata ?? this.metadata, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (color.present) { + map['color'] = Variable(color.value); + } + if (metadata.present) { + map['metadata'] = Variable(metadata.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ContainerCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('color: $color, ') + ..write('metadata: $metadata, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Tab extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Tab(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn source = GeneratedColumn( + 'source', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'REFERENCES tab(id)ON DELETE SET NULL', + ); + late final GeneratedColumn containerId = GeneratedColumn( + 'container_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'REFERENCES container(id)ON DELETE CASCADE', + ); + late final GeneratedColumn orderKey = GeneratedColumn( + 'order_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn tabMode = GeneratedColumn( + 'tab_mode', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn isolationContextId = + GeneratedColumn( + 'isolation_context_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn isProbablyReaderable = GeneratedColumn( + 'is_probably_readerable', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentMarkdown = + GeneratedColumn( + 'extracted_content_markdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentPlain = + GeneratedColumn( + 'extracted_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentMarkdown = + GeneratedColumn( + 'full_content_markdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentPlain = GeneratedColumn( + 'full_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn timestamp = GeneratedColumn( + 'timestamp', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [ + id, + source, + parentId, + containerId, + orderKey, + url, + title, + tabMode, + isolationContextId, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + timestamp, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tab'; + @override + Set get $primaryKey => {id}; + @override + TabData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TabData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + source: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}source'], + )!, + parentId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}parent_id'], + ), + containerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}container_id'], + ), + orderKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}order_key'], + )!, + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + ), + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + tabMode: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}tab_mode'], + )!, + isolationContextId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}isolation_context_id'], + ), + isProbablyReaderable: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}is_probably_readerable'], + ), + extractedContentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_markdown'], + ), + extractedContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + ), + fullContentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_markdown'], + ), + fullContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + ), + timestamp: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}timestamp'], + )!, + ); + } + + @override + Tab createAlias(String alias) { + return Tab(attachedDatabase, alias); + } + + @override + List get customConstraints => const [ + 'CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))', + ]; + @override + bool get dontWriteConstraints => true; +} + +class TabData extends DataClass implements Insertable { + final String id; + final int source; + final String? parentId; + final String? containerId; + final String orderKey; + final String? url; + final String? title; + final int tabMode; + final String? isolationContextId; + final int? isProbablyReaderable; + final String? extractedContentMarkdown; + final String? extractedContentPlain; + final String? fullContentMarkdown; + final String? fullContentPlain; + final int timestamp; + const TabData({ + required this.id, + required this.source, + this.parentId, + this.containerId, + required this.orderKey, + this.url, + this.title, + required this.tabMode, + this.isolationContextId, + this.isProbablyReaderable, + this.extractedContentMarkdown, + this.extractedContentPlain, + this.fullContentMarkdown, + this.fullContentPlain, + required this.timestamp, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['source'] = Variable(source); + if (!nullToAbsent || parentId != null) { + map['parent_id'] = Variable(parentId); + } + if (!nullToAbsent || containerId != null) { + map['container_id'] = Variable(containerId); + } + map['order_key'] = Variable(orderKey); + if (!nullToAbsent || url != null) { + map['url'] = Variable(url); + } + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + map['tab_mode'] = Variable(tabMode); + if (!nullToAbsent || isolationContextId != null) { + map['isolation_context_id'] = Variable(isolationContextId); + } + if (!nullToAbsent || isProbablyReaderable != null) { + map['is_probably_readerable'] = Variable(isProbablyReaderable); + } + if (!nullToAbsent || extractedContentMarkdown != null) { + map['extracted_content_markdown'] = Variable( + extractedContentMarkdown, + ); + } + if (!nullToAbsent || extractedContentPlain != null) { + map['extracted_content_plain'] = Variable(extractedContentPlain); + } + if (!nullToAbsent || fullContentMarkdown != null) { + map['full_content_markdown'] = Variable(fullContentMarkdown); + } + if (!nullToAbsent || fullContentPlain != null) { + map['full_content_plain'] = Variable(fullContentPlain); + } + map['timestamp'] = Variable(timestamp); + return map; + } + + factory TabData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TabData( + id: serializer.fromJson(json['id']), + source: serializer.fromJson(json['source']), + parentId: serializer.fromJson(json['parentId']), + containerId: serializer.fromJson(json['containerId']), + orderKey: serializer.fromJson(json['orderKey']), + url: serializer.fromJson(json['url']), + title: serializer.fromJson(json['title']), + tabMode: serializer.fromJson(json['tabMode']), + isolationContextId: serializer.fromJson( + json['isolationContextId'], + ), + isProbablyReaderable: serializer.fromJson( + json['isProbablyReaderable'], + ), + extractedContentMarkdown: serializer.fromJson( + json['extractedContentMarkdown'], + ), + extractedContentPlain: serializer.fromJson( + json['extractedContentPlain'], + ), + fullContentMarkdown: serializer.fromJson( + json['fullContentMarkdown'], + ), + fullContentPlain: serializer.fromJson(json['fullContentPlain']), + timestamp: serializer.fromJson(json['timestamp']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'source': serializer.toJson(source), + 'parentId': serializer.toJson(parentId), + 'containerId': serializer.toJson(containerId), + 'orderKey': serializer.toJson(orderKey), + 'url': serializer.toJson(url), + 'title': serializer.toJson(title), + 'tabMode': serializer.toJson(tabMode), + 'isolationContextId': serializer.toJson(isolationContextId), + 'isProbablyReaderable': serializer.toJson(isProbablyReaderable), + 'extractedContentMarkdown': serializer.toJson( + extractedContentMarkdown, + ), + 'extractedContentPlain': serializer.toJson( + extractedContentPlain, + ), + 'fullContentMarkdown': serializer.toJson(fullContentMarkdown), + 'fullContentPlain': serializer.toJson(fullContentPlain), + 'timestamp': serializer.toJson(timestamp), + }; + } + + TabData copyWith({ + String? id, + int? source, + Value parentId = const Value.absent(), + Value containerId = const Value.absent(), + String? orderKey, + Value url = const Value.absent(), + Value title = const Value.absent(), + int? tabMode, + Value isolationContextId = const Value.absent(), + Value isProbablyReaderable = const Value.absent(), + Value extractedContentMarkdown = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentMarkdown = const Value.absent(), + Value fullContentPlain = const Value.absent(), + int? timestamp, + }) => TabData( + id: id ?? this.id, + source: source ?? this.source, + parentId: parentId.present ? parentId.value : this.parentId, + containerId: containerId.present ? containerId.value : this.containerId, + orderKey: orderKey ?? this.orderKey, + url: url.present ? url.value : this.url, + title: title.present ? title.value : this.title, + tabMode: tabMode ?? this.tabMode, + isolationContextId: isolationContextId.present + ? isolationContextId.value + : this.isolationContextId, + isProbablyReaderable: isProbablyReaderable.present + ? isProbablyReaderable.value + : this.isProbablyReaderable, + extractedContentMarkdown: extractedContentMarkdown.present + ? extractedContentMarkdown.value + : this.extractedContentMarkdown, + extractedContentPlain: extractedContentPlain.present + ? extractedContentPlain.value + : this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown.present + ? fullContentMarkdown.value + : this.fullContentMarkdown, + fullContentPlain: fullContentPlain.present + ? fullContentPlain.value + : this.fullContentPlain, + timestamp: timestamp ?? this.timestamp, + ); + TabData copyWithCompanion(TabCompanion data) { + return TabData( + id: data.id.present ? data.id.value : this.id, + source: data.source.present ? data.source.value : this.source, + parentId: data.parentId.present ? data.parentId.value : this.parentId, + containerId: data.containerId.present + ? data.containerId.value + : this.containerId, + orderKey: data.orderKey.present ? data.orderKey.value : this.orderKey, + url: data.url.present ? data.url.value : this.url, + title: data.title.present ? data.title.value : this.title, + tabMode: data.tabMode.present ? data.tabMode.value : this.tabMode, + isolationContextId: data.isolationContextId.present + ? data.isolationContextId.value + : this.isolationContextId, + isProbablyReaderable: data.isProbablyReaderable.present + ? data.isProbablyReaderable.value + : this.isProbablyReaderable, + extractedContentMarkdown: data.extractedContentMarkdown.present + ? data.extractedContentMarkdown.value + : this.extractedContentMarkdown, + extractedContentPlain: data.extractedContentPlain.present + ? data.extractedContentPlain.value + : this.extractedContentPlain, + fullContentMarkdown: data.fullContentMarkdown.present + ? data.fullContentMarkdown.value + : this.fullContentMarkdown, + fullContentPlain: data.fullContentPlain.present + ? data.fullContentPlain.value + : this.fullContentPlain, + timestamp: data.timestamp.present ? data.timestamp.value : this.timestamp, + ); + } + + @override + String toString() { + return (StringBuffer('TabData(') + ..write('id: $id, ') + ..write('source: $source, ') + ..write('parentId: $parentId, ') + ..write('containerId: $containerId, ') + ..write('orderKey: $orderKey, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('tabMode: $tabMode, ') + ..write('isolationContextId: $isolationContextId, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('timestamp: $timestamp') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + source, + parentId, + containerId, + orderKey, + url, + title, + tabMode, + isolationContextId, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + timestamp, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TabData && + other.id == this.id && + other.source == this.source && + other.parentId == this.parentId && + other.containerId == this.containerId && + other.orderKey == this.orderKey && + other.url == this.url && + other.title == this.title && + other.tabMode == this.tabMode && + other.isolationContextId == this.isolationContextId && + other.isProbablyReaderable == this.isProbablyReaderable && + other.extractedContentMarkdown == this.extractedContentMarkdown && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentMarkdown == this.fullContentMarkdown && + other.fullContentPlain == this.fullContentPlain && + other.timestamp == this.timestamp); +} + +class TabCompanion extends UpdateCompanion { + final Value id; + final Value source; + final Value parentId; + final Value containerId; + final Value orderKey; + final Value url; + final Value title; + final Value tabMode; + final Value isolationContextId; + final Value isProbablyReaderable; + final Value extractedContentMarkdown; + final Value extractedContentPlain; + final Value fullContentMarkdown; + final Value fullContentPlain; + final Value timestamp; + final Value rowid; + const TabCompanion({ + this.id = const Value.absent(), + this.source = const Value.absent(), + this.parentId = const Value.absent(), + this.containerId = const Value.absent(), + this.orderKey = const Value.absent(), + this.url = const Value.absent(), + this.title = const Value.absent(), + this.tabMode = const Value.absent(), + this.isolationContextId = const Value.absent(), + this.isProbablyReaderable = const Value.absent(), + this.extractedContentMarkdown = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentMarkdown = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.timestamp = const Value.absent(), + this.rowid = const Value.absent(), + }); + TabCompanion.insert({ + required String id, + required int source, + this.parentId = const Value.absent(), + this.containerId = const Value.absent(), + required String orderKey, + this.url = const Value.absent(), + this.title = const Value.absent(), + this.tabMode = const Value.absent(), + this.isolationContextId = const Value.absent(), + this.isProbablyReaderable = const Value.absent(), + this.extractedContentMarkdown = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentMarkdown = const Value.absent(), + this.fullContentPlain = const Value.absent(), + required int timestamp, + this.rowid = const Value.absent(), + }) : id = Value(id), + source = Value(source), + orderKey = Value(orderKey), + timestamp = Value(timestamp); + static Insertable custom({ + Expression? id, + Expression? source, + Expression? parentId, + Expression? containerId, + Expression? orderKey, + Expression? url, + Expression? title, + Expression? tabMode, + Expression? isolationContextId, + Expression? isProbablyReaderable, + Expression? extractedContentMarkdown, + Expression? extractedContentPlain, + Expression? fullContentMarkdown, + Expression? fullContentPlain, + Expression? timestamp, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (source != null) 'source': source, + if (parentId != null) 'parent_id': parentId, + if (containerId != null) 'container_id': containerId, + if (orderKey != null) 'order_key': orderKey, + if (url != null) 'url': url, + if (title != null) 'title': title, + if (tabMode != null) 'tab_mode': tabMode, + if (isolationContextId != null) + 'isolation_context_id': isolationContextId, + if (isProbablyReaderable != null) + 'is_probably_readerable': isProbablyReaderable, + if (extractedContentMarkdown != null) + 'extracted_content_markdown': extractedContentMarkdown, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentMarkdown != null) + 'full_content_markdown': fullContentMarkdown, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (timestamp != null) 'timestamp': timestamp, + if (rowid != null) 'rowid': rowid, + }); + } + + TabCompanion copyWith({ + Value? id, + Value? source, + Value? parentId, + Value? containerId, + Value? orderKey, + Value? url, + Value? title, + Value? tabMode, + Value? isolationContextId, + Value? isProbablyReaderable, + Value? extractedContentMarkdown, + Value? extractedContentPlain, + Value? fullContentMarkdown, + Value? fullContentPlain, + Value? timestamp, + Value? rowid, + }) { + return TabCompanion( + id: id ?? this.id, + source: source ?? this.source, + parentId: parentId ?? this.parentId, + containerId: containerId ?? this.containerId, + orderKey: orderKey ?? this.orderKey, + url: url ?? this.url, + title: title ?? this.title, + tabMode: tabMode ?? this.tabMode, + isolationContextId: isolationContextId ?? this.isolationContextId, + isProbablyReaderable: isProbablyReaderable ?? this.isProbablyReaderable, + extractedContentMarkdown: + extractedContentMarkdown ?? this.extractedContentMarkdown, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown ?? this.fullContentMarkdown, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + timestamp: timestamp ?? this.timestamp, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (source.present) { + map['source'] = Variable(source.value); + } + if (parentId.present) { + map['parent_id'] = Variable(parentId.value); + } + if (containerId.present) { + map['container_id'] = Variable(containerId.value); + } + if (orderKey.present) { + map['order_key'] = Variable(orderKey.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (tabMode.present) { + map['tab_mode'] = Variable(tabMode.value); + } + if (isolationContextId.present) { + map['isolation_context_id'] = Variable(isolationContextId.value); + } + if (isProbablyReaderable.present) { + map['is_probably_readerable'] = Variable(isProbablyReaderable.value); + } + if (extractedContentMarkdown.present) { + map['extracted_content_markdown'] = Variable( + extractedContentMarkdown.value, + ); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = Variable( + extractedContentPlain.value, + ); + } + if (fullContentMarkdown.present) { + map['full_content_markdown'] = Variable( + fullContentMarkdown.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = Variable(fullContentPlain.value); + } + if (timestamp.present) { + map['timestamp'] = Variable(timestamp.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TabCompanion(') + ..write('id: $id, ') + ..write('source: $source, ') + ..write('parentId: $parentId, ') + ..write('containerId: $containerId, ') + ..write('orderKey: $orderKey, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('tabMode: $tabMode, ') + ..write('isolationContextId: $isolationContextId, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('timestamp: $timestamp, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class TabFts extends Table + with TableInfo, VirtualTableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + TabFts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentPlain = + GeneratedColumn( + 'extracted_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentPlain = GeneratedColumn( + 'full_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [ + title, + url, + extractedContentPlain, + fullContentPlain, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tab_fts'; + @override + Set get $primaryKey => const {}; + @override + TabFtsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TabFtsData( + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + ), + extractedContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + ), + fullContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + ), + ); + } + + @override + TabFts createAlias(String alias) { + return TabFts(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; + @override + String get moduleAndArgs => + 'fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize="trigram")'; +} + +class TabFtsData extends DataClass implements Insertable { + final String? title; + final String? url; + final String? extractedContentPlain; + final String? fullContentPlain; + const TabFtsData({ + this.title, + this.url, + this.extractedContentPlain, + this.fullContentPlain, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + if (!nullToAbsent || url != null) { + map['url'] = Variable(url); + } + if (!nullToAbsent || extractedContentPlain != null) { + map['extracted_content_plain'] = Variable(extractedContentPlain); + } + if (!nullToAbsent || fullContentPlain != null) { + map['full_content_plain'] = Variable(fullContentPlain); + } + return map; + } + + factory TabFtsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TabFtsData( + title: serializer.fromJson(json['title']), + url: serializer.fromJson(json['url']), + extractedContentPlain: serializer.fromJson( + json['extractedContentPlain'], + ), + fullContentPlain: serializer.fromJson(json['fullContentPlain']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'title': serializer.toJson(title), + 'url': serializer.toJson(url), + 'extractedContentPlain': serializer.toJson( + extractedContentPlain, + ), + 'fullContentPlain': serializer.toJson(fullContentPlain), + }; + } + + TabFtsData copyWith({ + Value title = const Value.absent(), + Value url = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentPlain = const Value.absent(), + }) => TabFtsData( + title: title.present ? title.value : this.title, + url: url.present ? url.value : this.url, + extractedContentPlain: extractedContentPlain.present + ? extractedContentPlain.value + : this.extractedContentPlain, + fullContentPlain: fullContentPlain.present + ? fullContentPlain.value + : this.fullContentPlain, + ); + TabFtsData copyWithCompanion(TabFtsCompanion data) { + return TabFtsData( + title: data.title.present ? data.title.value : this.title, + url: data.url.present ? data.url.value : this.url, + extractedContentPlain: data.extractedContentPlain.present + ? data.extractedContentPlain.value + : this.extractedContentPlain, + fullContentPlain: data.fullContentPlain.present + ? data.fullContentPlain.value + : this.fullContentPlain, + ); + } + + @override + String toString() { + return (StringBuffer('TabFtsData(') + ..write('title: $title, ') + ..write('url: $url, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(title, url, extractedContentPlain, fullContentPlain); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TabFtsData && + other.title == this.title && + other.url == this.url && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentPlain == this.fullContentPlain); +} + +class TabFtsCompanion extends UpdateCompanion { + final Value title; + final Value url; + final Value extractedContentPlain; + final Value fullContentPlain; + final Value rowid; + const TabFtsCompanion({ + this.title = const Value.absent(), + this.url = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + TabFtsCompanion.insert({ + this.title = const Value.absent(), + this.url = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? title, + Expression? url, + Expression? extractedContentPlain, + Expression? fullContentPlain, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (title != null) 'title': title, + if (url != null) 'url': url, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (rowid != null) 'rowid': rowid, + }); + } + + TabFtsCompanion copyWith({ + Value? title, + Value? url, + Value? extractedContentPlain, + Value? fullContentPlain, + Value? rowid, + }) { + return TabFtsCompanion( + title: title ?? this.title, + url: url ?? this.url, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (title.present) { + map['title'] = Variable(title.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = Variable( + extractedContentPlain.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = Variable(fullContentPlain.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TabFtsCompanion(') + ..write('title: $title, ') + ..write('url: $url, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV6 extends GeneratedDatabase { + DatabaseAtV6(QueryExecutor e) : super(e); + late final Container container = Container(this); + late final Tab tab = Tab(this); + late final TabFts tabFts = TabFts(this); + late final Trigger tabMaintainParentChainOnDelete = Trigger( + 'CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END', + 'tab_maintain_parent_chain_on_delete', + ); + late final Trigger tabAfterInsert = Trigger( + 'CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_insert', + ); + late final Trigger tabAfterDelete = Trigger( + 'CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END', + 'tab_after_delete', + ); + late final Trigger tabAfterUpdate = Trigger( + 'CREATE TRIGGER tab_after_update AFTER UPDATE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_update', + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + container, + tab, + tabFts, + tabMaintainParentChainOnDelete, + tabAfterInsert, + tabAfterDelete, + tabAfterUpdate, + ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'container', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.insert, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.update, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + ]); + @override + int get schemaVersion => 6; +} diff --git a/app/test/drift/user/generated/schema.dart b/app/test/drift/user/generated/schema.dart index b2b7404b..f5e870e5 100644 --- a/app/test/drift/user/generated/schema.dart +++ b/app/test/drift/user/generated/schema.dart @@ -1,6 +1,7 @@ // dart format width=80 -// GENERATED CODE, DO NOT EDIT BY HAND. -// ignore_for_file: type=lint +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// import 'package:drift/drift.dart'; import 'package:drift/internal/migrations.dart'; import 'schema_v1.dart' as v1; diff --git a/app/test/drift/user/generated/schema_v1.dart b/app/test/drift/user/generated/schema_v1.dart index 24f36374..d9952997 100644 --- a/app/test/drift/user/generated/schema_v1.dart +++ b/app/test/drift/user/generated/schema_v1.dart @@ -1,7 +1,8 @@ // dart format width=80 import 'dart:typed_data' as i2; -// GENERATED CODE, DO NOT EDIT BY HAND. -// ignore_for_file: type=lint +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// import 'package:drift/drift.dart'; class Setting extends Table with TableInfo { diff --git a/app/test/drift/user/generated/schema_v2.dart b/app/test/drift/user/generated/schema_v2.dart index 2c3f9f61..cb629059 100644 --- a/app/test/drift/user/generated/schema_v2.dart +++ b/app/test/drift/user/generated/schema_v2.dart @@ -1,7 +1,8 @@ // dart format width=80 import 'dart:typed_data' as i2; -// GENERATED CODE, DO NOT EDIT BY HAND. -// ignore_for_file: type=lint +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// import 'package:drift/drift.dart'; class Setting extends Table with TableInfo { diff --git a/app/test/features/geckoview/features/tabs/data/entities/tab_mode_test.dart b/app/test/features/geckoview/features/tabs/data/entities/tab_mode_test.dart new file mode 100644 index 00000000..e4929a7d --- /dev/null +++ b/app/test/features/geckoview/features/tabs/data/entities/tab_mode_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; + +void main() { + group('TabMode value semantics', () { + test('isolated modes with same context are equal and hash equally', () { + final first = TabMode.isolated('iso1_same'); + final second = TabMode.isolated('iso1_same'); + + expect(first, equals(second)); + expect(first.hashCode, equals(second.hashCode)); + }); + + test('isolated modes with different contexts are not equal', () { + final first = TabMode.isolated('iso1_a'); + final second = TabMode.isolated('iso1_b'); + + expect(first, isNot(equals(second))); + }); + }); +} diff --git a/packages/flutter_mozilla_components/javascript/container_proxy/src/store/Store.ts b/packages/flutter_mozilla_components/javascript/container_proxy/src/store/Store.ts index 5eecbdad..c0ab6718 100644 --- a/packages/flutter_mozilla_components/javascript/container_proxy/src/store/Store.ts +++ b/packages/flutter_mozilla_components/javascript/container_proxy/src/store/Store.ts @@ -93,8 +93,38 @@ export class Store { return this.siteAssignments.has(uri.origin) } + /** + * Returns the effective proxy relation for a context ID, mirroring the + * fallback logic in getProxiesForContainer: explicit relation first, + * then 'general' for non-private contexts, then empty. + */ + private getEffectiveRelation(contextId: string): string[] { + return this.relations[contextId] + ?? ((contextId !== 'private') ? this.relations['general'] : undefined) + ?? []; + } + isSiteOriginInSameContext(uri: URL, contextId: string): boolean { - return this.siteAssignments.get(uri.origin) === contextId; + const assignedContextId = this.siteAssignments.get(uri.origin); + if (assignedContextId === undefined) return false; + if (assignedContextId === contextId) return true; + + // Context equivalence: compare effective proxy relations (including + // fallback to 'general') so that isolated tabs in non-proxied containers + // or containers relying on the general relation are treated as compatible. + const assignedRelation = this.getEffectiveRelation(assignedContextId); + const currentRelation = this.getEffectiveRelation(contextId); + + // Only treat as equivalent if both have actual proxy relations — + // empty relations mean no proxy, and different non-proxied contexts + // should not be considered equivalent. + if (assignedRelation.length > 0 && + assignedRelation.length === currentRelation.length && + assignedRelation.every((id, i) => id === currentRelation[i])) { + return true; + } + + return false; } getAllProxies(): ProxySettings[] { diff --git a/packages/flutter_mozilla_components/javascript/container_proxy/test/unit/Store.test.ts b/packages/flutter_mozilla_components/javascript/container_proxy/test/unit/Store.test.ts index a79b0518..8bf395e6 100644 --- a/packages/flutter_mozilla_components/javascript/container_proxy/test/unit/Store.test.ts +++ b/packages/flutter_mozilla_components/javascript/container_proxy/test/unit/Store.test.ts @@ -164,4 +164,31 @@ describe('Store', () => { expect(relations.container2).to.be.deep.equal(['proxy2']) }) }) + + describe('isSiteOriginInSameContext', function () { + it('should allow proxy-equivalent isolated context', async () => { + store.setSiteAssignments(new Map([['https://example.com/page', 'container_ctx']])) + await store.setContainerProxyRelation('container_ctx', 'proxy_ctx') + await store.setContainerProxyRelation('iso1_ctx', 'proxy_ctx') + + const result = store.isSiteOriginInSameContext(new URL('https://example.com/other'), 'iso1_ctx') + expect(result).to.be.equal(true) + }) + + it('should allow exact context match', async () => { + store.setSiteAssignments(new Map([['https://exact.example/path', 'iso1_exact']])) + + const result = store.isSiteOriginInSameContext(new URL('https://exact.example/another'), 'iso1_exact') + expect(result).to.be.equal(true) + }) + + it('should block when contexts are not equivalent', async () => { + store.setSiteAssignments(new Map([['https://blocked.example/path', 'container_blocked']])) + await store.setContainerProxyRelation('container_blocked', 'proxy_one') + await store.setContainerProxyRelation('iso1_blocked', 'proxy_two') + + const result = store.isSiteOriginInSameContext(new URL('https://blocked.example/another'), 'iso1_blocked') + expect(result).to.be.equal(false) + }) + }) })