add isolated tab feature

This commit is contained in:
Fabian Freund
2026-02-27 08:29:50 +01:00
parent 3ef567cc19
commit d24675094d
84 changed files with 3783 additions and 513 deletions
+457
View File
@@ -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>(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>(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"
}
]
}
]
}
+35
View File
@@ -27,6 +27,10 @@ class AppColors extends ThemeExtension<AppColors> {
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<AppColors> {
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<AppColors> {
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<AppColors> {
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<AppColors> {
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(
+1 -1
View File
@@ -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 = ' ';
+1
View File
@@ -664,6 +664,7 @@ const _$TabTypeEnumMap = {
TabType.regular: 'regular',
TabType.private: 'private',
TabType.child: 'child',
TabType.isolated: 'isolated',
};
mixin $TabViewRoute on GoRouteData {
@@ -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,
);
@@ -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
@@ -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);
@@ -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,
@@ -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
@@ -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 {
@@ -55,7 +55,7 @@ final class SelectionActionServiceProvider
}
String _$selectionActionServiceHash() =>
r'75fa0fa770e74e49554d4098102fde9f9e531c4f';
r'75701bb0be3302b461d296a7b0b28e3a30a9d346';
@ProviderFor(eventService)
final eventServiceProvider = EventServiceProvider._();
@@ -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<bool> 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()
@@ -41,7 +41,7 @@ final class TabStatesProvider
}
}
String _$tabStatesHash() => r'975de1242f42e2017754dc560125788b83ad8b72';
String _$tabStatesHash() => r'0fd97193ff963006e1943436b25b025dd3885c65';
abstract class _$TabStates extends $Notifier<Map<String, TabState>> {
Map<String, TabState> build();
@@ -258,7 +258,7 @@ final class IsTabTunneledProvider
}
}
String _$isTabTunneledHash() => r'55a42ccb73d1921eed27d32f6da3a54680d4ac8c';
String _$isTabTunneledHash() => r'7265f5e6f32056846d3df38f48476d10c3a70bac';
final class IsTabTunneledFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<bool>, String?> {
@@ -358,7 +358,7 @@ final class SelectedTabTypeProvider
}
}
String _$selectedTabTypeHash() => r'53093fc6db8becde7500163661e087fb0a2eb955';
String _$selectedTabTypeHash() => r'fe14b5c9f81d8f5859ae9d758e440c42908e8a54';
@ProviderFor(selectedTabContainerId)
final selectedTabContainerIdProvider = SelectedTabContainerIdProvider._();
@@ -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 = <String>{};
final _closeLock = Lock();
final _pendingIsolationCleanup = <String>{};
bool hasLaunchedFromIntent(String? tabId) {
if (tabId == null) {
@@ -79,13 +86,13 @@ class TabRepository extends _$TabRepository {
}
Future<String> 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<String, String>? 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<void> closeTab(String tabId) async {
if (ref.read(selectedTabProvider) == tabId) {
await _selectNextTab(tabId);
}
Future<void> 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<void> closeTabs(List<String> tabIds) async {
final selectedTab = ref.read(selectedTabProvider);
if (selectedTab.mapNotNull(tabIds.contains) ?? false) {
await _selectNextTab(selectedTab!);
Future<void> closeTabs(List<String> 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<void> _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<void> undoClose() {
return _tabsService.undo();
}
/// Cleans up isolation contexts from previous crashed sessions.
/// Called once after tab list stabilizes on startup.
// Future<void> _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<String>.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<String>.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(
@@ -41,7 +41,7 @@ final class TabRepositoryProvider
}
}
String _$tabRepositoryHash() => r'd77e4e74bb7ee1b466172bca245b05c6bf03196c';
String _$tabRepositoryHash() => r'a6953b50e4b9d0e7e297a058872b9ad1c7d55c1a';
abstract class _$TabRepository extends $Notifier<void> {
void build();
@@ -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 = <String>{};
final _recomputeLock = Lock();
Future<void> _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<void> _recomputeIsolatedProxyAliases() async {
final db = ref.read(tabDatabaseProvider);
final contextContainerMap = <String, Set<String>>{};
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, () => <String>{})
.add(containerId);
}
final containers = await ref
.read(containerRepositoryProvider.notifier)
.getAllContainersWithCount();
// Build set of container IDs that have useProxy enabled.
final proxiedContainerIds = <String>{
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 = <String>{
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,
);
},
);
}
}
@@ -42,7 +42,7 @@ final class ProxySettingsReplicationProvider
}
String _$proxySettingsReplicationHash() =>
r'e4c5e35b9aab2aae60e3f09a99472f98a7beb69e';
r'79eca77aa476ed46cfe342e52926ea1cf956dc70';
abstract class _$ProxySettingsReplication extends $Notifier<void> {
void build();
@@ -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<bool> showSiteSettingsBadge(Ref ref) async {
final permissions = await ref.watch(
sitePermissionsRepositoryProvider(
origin: tabState.url.origin,
isPrivate: tabState.isPrivate,
isPrivate: tabState.tabMode is PrivateTabMode,
).future,
);
@@ -48,4 +48,4 @@ final class ShowSiteSettingsBadgeProvider
}
String _$showSiteSettingsBadgeHash() =>
r'd1499809901363705d23b17aab375fd414080544';
r'f44282c62556c39d439c499519a2a9861082cfcb';
@@ -798,6 +798,22 @@ class _Browser extends HookConsumerWidget {
final overlayBuilder = ref.watch(overlayControllerProvider);
Future<bool> 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<TabDragData>(
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);
@@ -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(
@@ -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,
@@ -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<BrowserView>
.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<BrowserView>
.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<BrowserView>
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<BrowserView>
type: 'new_private_tab',
localizedTitle: 'New Private Tab',
),
const ShortcutItem(
type: 'new_isolated_tab',
localizedTitle: 'New Isolated Tab',
),
]);
initializationCompleter.complete();
@@ -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,
@@ -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
@@ -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 {
@@ -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);
@@ -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<bool> _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) {
@@ -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 = <String, int>{};
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());
@@ -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:
@@ -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,
);
@@ -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) {
@@ -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,
),
@@ -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) {
@@ -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,
);
@@ -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,
);
@@ -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,
);
@@ -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,
);
@@ -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<void> openTab(bool isPrivate) async {
Future<void> 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<TabMode>(
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',
@@ -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,
@@ -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(
@@ -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),
),
),
],
@@ -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,
),
@@ -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);
}
},
);
},
);
},
),
],
),
],
);
}
}
@@ -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<TabDatabase>
Selectable<String> 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 = <TabModeDbValue>[];
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<TabDatabase>
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<TabDatabase>
)
..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 = <TabModeDbValue>[];
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)!);
@@ -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<String> deletedIsolationContextIds;
final int deletedCount;
const SyncTabsResult({
required this.deletedIsolationContextIds,
required this.deletedCount,
});
}
class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
final _undoHistory = <String, TabData>{};
Timer? _clearHistoryTimer;
@@ -45,12 +56,25 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
SingleOrNullSelectable<TabData> getTabDataById(String id) =>
db.tab.select()..where((t) => t.id.equals(id));
SingleOrNullSelectable<bool?> getTabIsPrivate(String tabId) {
SingleOrNullSelectable<TabMode> 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<String?> 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<String> getAllTabIds() {
@@ -124,18 +148,24 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
Future<String> upsertTabTransactional(
Future<String> Function() createTab, {
required Value<bool?> isPrivate,
required Value<String?> parentId,
Value<String?> containerId = const Value.absent(),
Value<String?> orderKey = const Value.absent(),
Value<Uri?> url = const Value.absent(),
Value<String?> title = const Value.absent(),
Value<TabMode> tabMode = const Value.absent(),
}) {
return db.transaction(() async {
final tabId = await createTab();
final currentOrderKey =
orderKey.value ??
await _generateOrderKey(parentId: parentId, containerId: containerId);
final Value<TabModeDbValue> persistedTabMode = tabMode.present
? Value(tabMode.value.toDbValue())
: const Value.absent();
final Value<String?> isolationContextId = tabMode.present
? Value(tabMode.value.isolationContextId)
: const Value.absent();
await db.tab.insertOne(
TabCompanion.insert(
@@ -146,8 +176,9 @@ class TabDao extends DatabaseAccessor<TabDatabase> 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<TabDatabase> 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<TabDatabase> with $TabDaoMixin {
Future<String> insertTab(
String tabId, {
required TabSource source,
required Value<bool?> isPrivate,
required Value<String?> parentId,
Value<String?> containerId = const Value.absent(),
Value<String?> orderKey = const Value.absent(),
Value<Uri?> url = const Value.absent(),
Value<String?> title = const Value.absent(),
Value<TabMode> tabMode = const Value.absent(),
}) {
return db.transaction(() async {
final currentOrderKey =
orderKey.value ??
await _generateOrderKey(parentId: parentId, containerId: containerId);
final Value<TabModeDbValue> persistedTabMode = tabMode.present
? Value(tabMode.value.toDbValue())
: const Value.absent();
final Value<String?> isolationContextId = tabMode.present
? Value(tabMode.value.isolationContextId)
: const Value.absent();
await db.tab.insertOne(
TabCompanion.insert(
@@ -190,9 +228,10 @@ class TabDao extends DatabaseAccessor<TabDatabase> 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<TabDatabase> 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<TabDatabase> 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<TabDatabase> 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<TabDatabase> with $TabDaoMixin {
});
}
Future<void> syncTabs({required List<String> retainTabIds}) {
/// Syncs DB tab rows with the engine's active tab list.
/// Returns metadata about deleted rows for follow-up cleanup.
Future<SyncTabsResult> syncTabs({required List<String> retainTabIds}) {
return db.transaction(() async {
final deleted =
await (db.tab.delete()..where((t) => t.id.isNotIn(retainTabIds)))
.goAndReturn();
final deletedIsolationContextIds = <String>{
for (final tab in deleted)
if (tab.isolationContextId != null) tab.isolationContextId!,
};
if (deleted.isNotEmpty) {
_clearHistoryTimer?.cancel();
@@ -361,6 +415,11 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
}),
onConflict: DoNothing(),
);
return SyncTabsResult(
deletedIsolationContextIds: deletedIsolationContextIds,
deletedCount: deleted.length,
);
});
}
@@ -391,6 +450,19 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
}
}
SingleSelectable<int> tabsInIsolationGroup(String contextId) {
return db.definitionsDrift.tabsInIsolationGroup(contextId: contextId);
}
Selectable<String?> allIsolationContextIds() {
return db.definitionsDrift.allIsolationContextIds();
}
Selectable<IsolatedContextContainerPairsResult>
isolatedContextContainerPairs() {
return db.definitionsDrift.isolatedContextContainerPairs();
}
Future<List<String>> getUnassignedTabsOlderThan(DateTime threshold) {
final query = selectOnly(db.tab)
..addColumns([db.tab.id])
@@ -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));
},
);
}
@@ -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<i1.DatabaseSchemaEntity> 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<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get source =>
columnsByName['source']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get parentId =>
columnsByName['parent_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get containerId =>
columnsByName['container_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get orderKey =>
columnsByName['order_key']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get url =>
columnsByName['url']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get title =>
columnsByName['title']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get tabMode =>
columnsByName['tab_mode']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get isolationContextId =>
columnsByName['isolation_context_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get isProbablyReaderable =>
columnsByName['is_probably_readerable']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get extractedContentMarkdown =>
columnsByName['extracted_content_markdown']!
as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get extractedContentPlain =>
columnsByName['extracted_content_plain']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get fullContentMarkdown =>
columnsByName['full_content_markdown']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get fullContentPlain =>
columnsByName['full_content_plain']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get timestamp =>
columnsByName['timestamp']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<int> _column_17(String aliasedName) =>
i1.GeneratedColumn<int>(
'tab_mode',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL DEFAULT 0',
defaultValue: const i1.CustomExpression('0'),
);
i1.GeneratedColumn<String> _column_18(String aliasedName) =>
i1.GeneratedColumn<String>(
'isolation_context_id',
aliasedName,
true,
type: i1.DriftSqlType.string,
$customConstraints: '',
);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5,
required Future<void> 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<void> Function(i1.Migrator m, Schema3 schema) from2To3,
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5,
required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from2To3: from2To3,
from3To4: from3To4,
from4To5: from4To5,
from5To6: from5To6,
),
);
@@ -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;
@@ -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<i3.Tab, List<i3.TabData>> _tabRefsTable(
i0.GeneratedDatabase db,
) => i0.MultiTypedResultKey.fromTable(
i8.ReadDatabaseContainer(db).resultSet<i3.Tab>('tab'),
i9.ReadDatabaseContainer(db).resultSet<i3.Tab>('tab'),
aliasName: i0.$_aliasNameGenerator(
i8.ReadDatabaseContainer(db).resultSet<i3.Container>('container').id,
i8.ReadDatabaseContainer(db).resultSet<i3.Tab>('tab').containerId,
i9.ReadDatabaseContainer(db).resultSet<i3.Container>('container').id,
i9.ReadDatabaseContainer(db).resultSet<i3.Tab>('tab').containerId,
),
);
@@ -58,7 +60,7 @@ final class $ContainerReferences
final manager = i3
.$TabTableManager(
$_db,
i8.ReadDatabaseContainer($_db).resultSet<i3.Tab>('tab'),
i9.ReadDatabaseContainer($_db).resultSet<i3.Tab>('tab'),
)
.filter((f) => f.containerId.id.sqlEquals($_itemColumn<String>('id')!));
@@ -110,7 +112,7 @@ class $ContainerFilterComposer
final i3.$TabFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.id,
referencedTable: i8.ReadDatabaseContainer($db).resultSet<i3.Tab>('tab'),
referencedTable: i9.ReadDatabaseContainer($db).resultSet<i3.Tab>('tab'),
getReferencedColumn: (t) => t.containerId,
builder:
(
@@ -119,7 +121,7 @@ class $ContainerFilterComposer
$removeJoinBuilderFromRootComposer,
}) => i3.$TabFilterComposer(
$db: $db,
$table: i8.ReadDatabaseContainer($db).resultSet<i3.Tab>('tab'),
$table: i9.ReadDatabaseContainer($db).resultSet<i3.Tab>('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<i3.Tab>('tab'),
referencedTable: i9.ReadDatabaseContainer($db).resultSet<i3.Tab>('tab'),
getReferencedColumn: (t) => t.containerId,
builder:
(
@@ -197,7 +199,7 @@ class $ContainerAnnotationComposer
$removeJoinBuilderFromRootComposer,
}) => i3.$TabAnnotationComposer(
$db: $db,
$table: i8.ReadDatabaseContainer($db).resultSet<i3.Tab>('tab'),
$table: i9.ReadDatabaseContainer($db).resultSet<i3.Tab>('tab'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
@@ -275,7 +277,7 @@ class $ContainerTableManager
db: db,
explicitlyWatchedTables: [
if (tabRefs)
i8.ReadDatabaseContainer(db).resultSet<i3.Tab>('tab'),
i9.ReadDatabaseContainer(db).resultSet<i3.Tab>('tab'),
],
addJoins: null,
getPrefetchedDataCallback: (items) async {
@@ -329,7 +331,8 @@ typedef $TabCreateCompanionBuilder =
required String orderKey,
i0.Value<Uri?> url,
i0.Value<String?> title,
i0.Value<bool?> isPrivate,
i0.Value<i7.TabModeDbValue> tabMode,
i0.Value<String?> isolationContextId,
i0.Value<bool?> isProbablyReaderable,
i0.Value<String?> extractedContentMarkdown,
i0.Value<String?> extractedContentPlain,
@@ -347,7 +350,8 @@ typedef $TabUpdateCompanionBuilder =
i0.Value<String> orderKey,
i0.Value<Uri?> url,
i0.Value<String?> title,
i0.Value<bool?> isPrivate,
i0.Value<i7.TabModeDbValue> tabMode,
i0.Value<String?> isolationContextId,
i0.Value<bool?> isProbablyReaderable,
i0.Value<String?> extractedContentMarkdown,
i0.Value<String?> 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<i3.Container>('container')
.createAlias(
i0.$_aliasNameGenerator(
i8.ReadDatabaseContainer(db).resultSet<i3.Tab>('tab').containerId,
i8.ReadDatabaseContainer(
i9.ReadDatabaseContainer(db).resultSet<i3.Tab>('tab').containerId,
i9.ReadDatabaseContainer(
db,
).resultSet<i3.Container>('container').id,
),
@@ -379,7 +383,7 @@ final class $TabReferences
final manager = i3
.$ContainerTableManager(
$_db,
i8.ReadDatabaseContainer($_db).resultSet<i3.Container>('container'),
i9.ReadDatabaseContainer($_db).resultSet<i3.Container>('container'),
)
.filter((f) => f.id.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_containerIdTable($_db));
@@ -430,8 +434,14 @@ class $TabFilterComposer extends i0.Composer<i0.GeneratedDatabase, i3.Tab> {
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<bool> get isPrivate => $composableBuilder(
column: $table.isPrivate,
i0.ColumnWithTypeConverterFilters<i7.TabModeDbValue, i7.TabModeDbValue, int>
get tabMode => $composableBuilder(
column: $table.tabMode,
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
);
i0.ColumnFilters<String> get isolationContextId => $composableBuilder(
column: $table.isolationContextId,
builder: (column) => i0.ColumnFilters(column),
);
@@ -469,7 +479,7 @@ class $TabFilterComposer extends i0.Composer<i0.GeneratedDatabase, i3.Tab> {
final i3.$ContainerFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.containerId,
referencedTable: i8.ReadDatabaseContainer(
referencedTable: i9.ReadDatabaseContainer(
$db,
).resultSet<i3.Container>('container'),
getReferencedColumn: (t) => t.id,
@@ -480,7 +490,7 @@ class $TabFilterComposer extends i0.Composer<i0.GeneratedDatabase, i3.Tab> {
$removeJoinBuilderFromRootComposer,
}) => i3.$ContainerFilterComposer(
$db: $db,
$table: i8.ReadDatabaseContainer(
$table: i9.ReadDatabaseContainer(
$db,
).resultSet<i3.Container>('container'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
@@ -531,8 +541,13 @@ class $TabOrderingComposer extends i0.Composer<i0.GeneratedDatabase, i3.Tab> {
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<bool> get isPrivate => $composableBuilder(
column: $table.isPrivate,
i0.ColumnOrderings<int> get tabMode => $composableBuilder(
column: $table.tabMode,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<String> get isolationContextId => $composableBuilder(
column: $table.isolationContextId,
builder: (column) => i0.ColumnOrderings(column),
);
@@ -570,7 +585,7 @@ class $TabOrderingComposer extends i0.Composer<i0.GeneratedDatabase, i3.Tab> {
final i3.$ContainerOrderingComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.containerId,
referencedTable: i8.ReadDatabaseContainer(
referencedTable: i9.ReadDatabaseContainer(
$db,
).resultSet<i3.Container>('container'),
getReferencedColumn: (t) => t.id,
@@ -581,7 +596,7 @@ class $TabOrderingComposer extends i0.Composer<i0.GeneratedDatabase, i3.Tab> {
$removeJoinBuilderFromRootComposer,
}) => i3.$ContainerOrderingComposer(
$db: $db,
$table: i8.ReadDatabaseContainer(
$table: i9.ReadDatabaseContainer(
$db,
).resultSet<i3.Container>('container'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
@@ -620,8 +635,13 @@ class $TabAnnotationComposer extends i0.Composer<i0.GeneratedDatabase, i3.Tab> {
i0.GeneratedColumn<String> get title =>
$composableBuilder(column: $table.title, builder: (column) => column);
i0.GeneratedColumn<bool> get isPrivate =>
$composableBuilder(column: $table.isPrivate, builder: (column) => column);
i0.GeneratedColumnWithTypeConverter<i7.TabModeDbValue, int> get tabMode =>
$composableBuilder(column: $table.tabMode, builder: (column) => column);
i0.GeneratedColumn<String> get isolationContextId => $composableBuilder(
column: $table.isolationContextId,
builder: (column) => column,
);
i0.GeneratedColumn<bool> get isProbablyReaderable => $composableBuilder(
column: $table.isProbablyReaderable,
@@ -655,7 +675,7 @@ class $TabAnnotationComposer extends i0.Composer<i0.GeneratedDatabase, i3.Tab> {
final i3.$ContainerAnnotationComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.containerId,
referencedTable: i8.ReadDatabaseContainer(
referencedTable: i9.ReadDatabaseContainer(
$db,
).resultSet<i3.Container>('container'),
getReferencedColumn: (t) => t.id,
@@ -666,7 +686,7 @@ class $TabAnnotationComposer extends i0.Composer<i0.GeneratedDatabase, i3.Tab> {
$removeJoinBuilderFromRootComposer,
}) => i3.$ContainerAnnotationComposer(
$db: $db,
$table: i8.ReadDatabaseContainer(
$table: i9.ReadDatabaseContainer(
$db,
).resultSet<i3.Container>('container'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
@@ -714,7 +734,8 @@ class $TabTableManager
i0.Value<String> orderKey = const i0.Value.absent(),
i0.Value<Uri?> url = const i0.Value.absent(),
i0.Value<String?> title = const i0.Value.absent(),
i0.Value<bool?> isPrivate = const i0.Value.absent(),
i0.Value<i7.TabModeDbValue> tabMode = const i0.Value.absent(),
i0.Value<String?> isolationContextId = const i0.Value.absent(),
i0.Value<bool?> isProbablyReaderable = const i0.Value.absent(),
i0.Value<String?> 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<Uri?> url = const i0.Value.absent(),
i0.Value<String?> title = const i0.Value.absent(),
i0.Value<bool?> isPrivate = const i0.Value.absent(),
i0.Value<i7.TabModeDbValue> tabMode = const i0.Value.absent(),
i0.Value<String?> isolationContextId = const i0.Value.absent(),
i0.Value<bool?> isProbablyReaderable = const i0.Value.absent(),
i0.Value<String?> 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<Tab, i3.TabData> {
requiredDuringInsert: false,
$customConstraints: '',
);
late final i0.GeneratedColumn<bool> isPrivate = i0.GeneratedColumn<bool>(
'is_private',
late final i0.GeneratedColumnWithTypeConverter<i7.TabModeDbValue, int>
tabMode = i0.GeneratedColumn<int>(
'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<i7.TabModeDbValue>(i3.Tab.$convertertabMode);
late final i0.GeneratedColumn<String> isolationContextId =
i0.GeneratedColumn<String>(
'isolation_context_id',
aliasedName,
true,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
$customConstraints: '',
);
late final i0.GeneratedColumn<bool> isProbablyReaderable =
i0.GeneratedColumn<bool>(
'is_probably_readerable',
@@ -1340,7 +1375,8 @@ class Tab extends i0.Table with i0.TableInfo<Tab, i3.TabData> {
orderKey,
url,
title,
isPrivate,
tabMode,
isolationContextId,
isProbablyReaderable,
extractedContentMarkdown,
extractedContentPlain,
@@ -1391,9 +1427,15 @@ class Tab extends i0.Table with i0.TableInfo<Tab, i3.TabData> {
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<Tab, i3.TabData> {
static i0.JsonTypeConverter2<i6.TabSource, int, int> $convertersource =
const i0.EnumIndexConverter<i6.TabSource>(i6.TabSource.values);
static i0.TypeConverter<Uri?, String?> $converterurl =
const i7.UriConverterNullable();
const i8.UriConverterNullable();
static i0.JsonTypeConverter2<i7.TabModeDbValue, int, int> $convertertabMode =
const i0.EnumIndexConverter<i7.TabModeDbValue>(i7.TabModeDbValue.values);
@override
List<String> 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<i3.TabData> {
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<i3.TabData> {
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<i3.TabData> {
if (!nullToAbsent || title != null) {
map['title'] = i0.Variable<String>(title);
}
if (!nullToAbsent || isPrivate != null) {
map['is_private'] = i0.Variable<bool>(isPrivate);
{
map['tab_mode'] = i0.Variable<int>(
i3.Tab.$convertertabMode.toSql(tabMode),
);
}
if (!nullToAbsent || isolationContextId != null) {
map['isolation_context_id'] = i0.Variable<String>(isolationContextId);
}
if (!nullToAbsent || isProbablyReaderable != null) {
map['is_probably_readerable'] = i0.Variable<bool>(isProbablyReaderable);
@@ -1527,7 +1582,12 @@ class TabData extends i0.DataClass implements i0.Insertable<i3.TabData> {
orderKey: serializer.fromJson<String>(json['order_key']),
url: serializer.fromJson<Uri?>(json['url']),
title: serializer.fromJson<String?>(json['title']),
isPrivate: serializer.fromJson<bool?>(json['is_private']),
tabMode: i3.Tab.$convertertabMode.fromJson(
serializer.fromJson<int>(json['tab_mode']),
),
isolationContextId: serializer.fromJson<String?>(
json['isolation_context_id'],
),
isProbablyReaderable: serializer.fromJson<bool?>(
json['is_probably_readerable'],
),
@@ -1557,7 +1617,10 @@ class TabData extends i0.DataClass implements i0.Insertable<i3.TabData> {
'order_key': serializer.toJson<String>(orderKey),
'url': serializer.toJson<Uri?>(url),
'title': serializer.toJson<String?>(title),
'is_private': serializer.toJson<bool?>(isPrivate),
'tab_mode': serializer.toJson<int>(
i3.Tab.$convertertabMode.toJson(tabMode),
),
'isolation_context_id': serializer.toJson<String?>(isolationContextId),
'is_probably_readerable': serializer.toJson<bool?>(isProbablyReaderable),
'extracted_content_markdown': serializer.toJson<String?>(
extractedContentMarkdown,
@@ -1579,7 +1642,8 @@ class TabData extends i0.DataClass implements i0.Insertable<i3.TabData> {
String? orderKey,
i0.Value<Uri?> url = const i0.Value.absent(),
i0.Value<String?> title = const i0.Value.absent(),
i0.Value<bool?> isPrivate = const i0.Value.absent(),
i7.TabModeDbValue? tabMode,
i0.Value<String?> isolationContextId = const i0.Value.absent(),
i0.Value<bool?> isProbablyReaderable = const i0.Value.absent(),
i0.Value<String?> extractedContentMarkdown = const i0.Value.absent(),
i0.Value<String?> extractedContentPlain = const i0.Value.absent(),
@@ -1594,7 +1658,10 @@ class TabData extends i0.DataClass implements i0.Insertable<i3.TabData> {
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<i3.TabData> {
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<i3.TabData> {
..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<i3.TabData> {
orderKey,
url,
title,
isPrivate,
tabMode,
isolationContextId,
isProbablyReaderable,
extractedContentMarkdown,
extractedContentPlain,
@@ -1692,7 +1764,8 @@ class TabData extends i0.DataClass implements i0.Insertable<i3.TabData> {
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<i3.TabData> {
final i0.Value<String> orderKey;
final i0.Value<Uri?> url;
final i0.Value<String?> title;
final i0.Value<bool?> isPrivate;
final i0.Value<i7.TabModeDbValue> tabMode;
final i0.Value<String?> isolationContextId;
final i0.Value<bool?> isProbablyReaderable;
final i0.Value<String?> extractedContentMarkdown;
final i0.Value<String?> extractedContentPlain;
@@ -1725,7 +1799,8 @@ class TabCompanion extends i0.UpdateCompanion<i3.TabData> {
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<i3.TabData> {
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<i3.TabData> {
i0.Expression<String>? orderKey,
i0.Expression<String>? url,
i0.Expression<String>? title,
i0.Expression<bool>? isPrivate,
i0.Expression<int>? tabMode,
i0.Expression<String>? isolationContextId,
i0.Expression<bool>? isProbablyReaderable,
i0.Expression<String>? extractedContentMarkdown,
i0.Expression<String>? extractedContentPlain,
@@ -1779,7 +1856,9 @@ class TabCompanion extends i0.UpdateCompanion<i3.TabData> {
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<i3.TabData> {
i0.Value<String>? orderKey,
i0.Value<Uri?>? url,
i0.Value<String?>? title,
i0.Value<bool?>? isPrivate,
i0.Value<i7.TabModeDbValue>? tabMode,
i0.Value<String?>? isolationContextId,
i0.Value<bool?>? isProbablyReaderable,
i0.Value<String?>? extractedContentMarkdown,
i0.Value<String?>? extractedContentPlain,
@@ -1819,7 +1899,8 @@ class TabCompanion extends i0.UpdateCompanion<i3.TabData> {
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<i3.TabData> {
if (title.present) {
map['title'] = i0.Variable<String>(title.value);
}
if (isPrivate.present) {
map['is_private'] = i0.Variable<bool>(isPrivate.value);
if (tabMode.present) {
map['tab_mode'] = i0.Variable<int>(
i3.Tab.$convertertabMode.toSql(tabMode.value),
);
}
if (isolationContextId.present) {
map['isolation_context_id'] = i0.Variable<String>(
isolationContextId.value,
);
}
if (isProbablyReaderable.present) {
map['is_probably_readerable'] = i0.Variable<bool>(
@@ -1903,7 +1991,8 @@ class TabCompanion extends i0.UpdateCompanion<i3.TabData> {
..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<int> optimizeFtsIndex() {
return customInsert(
@@ -2284,19 +2373,19 @@ class DefinitionsDrift extends i8.ModularAccessor {
).map((i0.QueryRow row) => row.read<String>('_c0'));
}
i0.Selectable<i9.TabQueryResult> queryTabsBasic({
i0.Selectable<i10.TabQueryResult> 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<String>(query), i0.Variable<int>(limit)],
readsFrom: {tab, tabFts},
).map(
(i0.QueryRow row) => i9.TabQueryResult(
(i0.QueryRow row) => i10.TabQueryResult(
id: row.read<String>('id'),
containerId: row.readNullable<String>('container_id'),
isPrivate: row.readNullable<bool>('is_private'),
tabMode: i3.Tab.$convertertabMode.fromSql(row.read<int>('tab_mode')),
title: row.readNullable<String>('title'),
url: row.readNullable<String>('url'),
cleanUrl: i3.Tab.$converterurl.fromSql(
@@ -2307,7 +2396,7 @@ class DefinitionsDrift extends i8.ModularAccessor {
);
}
i0.Selectable<i9.TabQueryResult> queryTabsFullContent({
i0.Selectable<i10.TabQueryResult> 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<String>(beforeMatch),
i0.Variable<String>(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<String>('id'),
containerId: row.readNullable<String>('container_id'),
isPrivate: row.readNullable<bool>('is_private'),
tabMode: i3.Tab.$convertertabMode.fromSql(row.read<int>('tab_mode')),
title: row.readNullable<String>('title'),
url: row.readNullable<String>('url'),
cleanUrl: i3.Tab.$converterurl.fromSql(
@@ -2443,13 +2532,13 @@ class DefinitionsDrift extends i8.ModularAccessor {
).map((i0.QueryRow row) => row.read<String>('id'));
}
i0.Selectable<i10.SiteAssignment> allAssignedSites() {
i0.Selectable<i11.SiteAssignment> 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<String>('id'),
contextualIdentity: row.read<String>('contextualIdentity'),
assignedSite: row.readNullable<String>('assigned_site'),
@@ -2465,14 +2554,46 @@ class DefinitionsDrift extends i8.ModularAccessor {
).map((i0.QueryRow row) => row.readNullable<String>('contextual_identity'));
}
i3.TabFts get tabFts => i8.ReadDatabaseContainer(
i0.Selectable<int> tabsInIsolationGroup({String? contextId}) {
return customSelect(
'SELECT COUNT(*) AS count FROM tab WHERE isolation_context_id = ?1',
variables: [i0.Variable<String>(contextId)],
readsFrom: {tab},
).map((i0.QueryRow row) => row.read<int>('count'));
}
i0.Selectable<String?> 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<String>('isolation_context_id'),
);
}
i0.Selectable<IsolatedContextContainerPairsResult>
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<String>('isolation_context_id'),
containerId: row.readNullable<String>('container_id'),
),
);
}
i3.TabFts get tabFts => i9.ReadDatabaseContainer(
attachedDatabase,
).resultSet<i3.TabFts>('tab_fts');
i3.Container get container => i8.ReadDatabaseContainer(
i3.Container get container => i9.ReadDatabaseContainer(
attachedDatabase,
).resultSet<i3.Container>('container');
i3.Tab get tab =>
i8.ReadDatabaseContainer(attachedDatabase).resultSet<i3.Tab>('tab');
i9.ReadDatabaseContainer(attachedDatabase).resultSet<i3.Tab>('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,
});
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
import 'package:weblibre/core/uuid.dart';
const _isolatedPrefix = 'iso1_';
/// Generates a new unique isolation context ID in the format `iso1_<uuid-v4>`.
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);
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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);
}
@@ -18,11 +18,12 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<Object?> get hashParameters => [
id,
containerId,
isPrivate,
tabMode,
title,
cleanUrl,
url,
@@ -155,6 +155,25 @@ Stream<List<SiteAssignment>> 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<Map<String, Set<String>>> watchIsolatedContextContainerMap(Ref ref) {
final db = ref.watch(tabDatabaseProvider);
return db.tabDao.isolatedContextContainerPairs().watch().map((pairs) {
final map = <String, Set<String>>{};
for (final p in pairs) {
map.putIfAbsent(p.isolationContextId!, () => {}).add(p.containerId!);
}
return map;
});
}
@Riverpod()
Stream<bool> watchIsCurrentSiteAssignedToContainer(Ref ref) {
final currentUri = ref.watch(
@@ -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<String, Set<String>>>,
Map<String, Set<String>>,
Stream<Map<String, Set<String>>>
>
with
$FutureModifier<Map<String, Set<String>>>,
$StreamProvider<Map<String, Set<String>>> {
/// 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<Map<String, Set<String>>> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<Map<String, Set<String>>> create(Ref ref) {
return watchIsolatedContextContainerMap(ref);
}
}
String _$watchIsolatedContextContainerMapHash() =>
r'6275a6c508777c306e2ababca83e2792d6d5efe1';
@ProviderFor(watchIsCurrentSiteAssignedToContainer)
final watchIsCurrentSiteAssignedToContainerProvider =
WatchIsCurrentSiteAssignedToContainerProvider._();
@@ -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<void> 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<int> 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();
@@ -41,7 +41,7 @@ final class TabDataRepositoryProvider
}
}
String _$tabDataRepositoryHash() => r'c1bdfbb576379a7d2ac7b2fe310dbec090e7fd4d';
String _$tabDataRepositoryHash() => r'41ca88333684db71f809e35874b0e5bdddde0f60';
abstract class _$TabDataRepository extends $Notifier<void> {
void build();
@@ -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,
@@ -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,
),
},
),
),
@@ -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),
@@ -336,7 +336,7 @@ final class SyncRepositoryProvider
SyncRepository create() => SyncRepository();
}
String _$syncRepositoryHash() => r'5bfe6deacfea9b14981a0b9769857f23b7dd478c';
String _$syncRepositoryHash() => r'c2765421a6cea9620e752aa9cbb37f79baf5b1c3';
abstract class _$SyncRepository extends $AsyncNotifier<SyncRepositoryState> {
FutureOr<SyncRepositoryState> build();
@@ -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
@@ -803,6 +803,7 @@ const _$TabTypeEnumMap = {
TabType.regular: 'regular',
TabType.private: 'private',
TabType.child: 'child',
TabType.isolated: 'isolated',
};
const _$TabIntentOpenSettingEnumMap = {
@@ -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,
);
@@ -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<WebPageInfo> 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),
);
@@ -162,7 +162,7 @@ final class PageInfoProvider
}
}
String _$pageInfoHash() => r'19116a520df214240254f95989818c1ac3bde6f6';
String _$pageInfoHash() => r'c8b7a44b675742436999941b526e9b268ebd139e';
final class PageInfoFamily extends $Family
with
+31
View File
@@ -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<bool> 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<bool>(
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, {
+3 -2
View File
@@ -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;
@@ -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<Bang, BangData> {
@@ -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<Bang, BangData> {
@@ -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<Bang, BangData> {
@@ -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<Bang, BangData> {
+7 -3
View File
@@ -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];
}
+3 -2
View File
@@ -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<Container, ContainerData> {
+3 -2
View File
@@ -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<Container, ContainerData> {
+3 -2
View File
@@ -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<Container, ContainerData> {
+3 -2
View File
@@ -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<Container, ContainerData> {
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -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;
+3 -2
View File
@@ -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<Setting, SettingData> {
+3 -2
View File
@@ -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<Setting, SettingData> {
@@ -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)));
});
});
}
@@ -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[] {
@@ -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)
})
})
})