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
@@ -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);
}
},
);
},
);
},
),
],
),
],
);
}
}