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
@@ -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();