improve tab (and other lexorank entities) sorting, ordering and reordering

This commit is contained in:
Fabian Freund
2026-05-02 04:50:53 +02:00
parent 222b920a47
commit 69f21bb7d8
43 changed files with 4965 additions and 418 deletions
@@ -33,6 +33,7 @@ import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolation_context.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_source.dart';
@@ -68,14 +69,6 @@ class TabRepository extends _$TabRepository {
_tabFromIntent.remove(tabId);
}
NewTabPosition _newTabPositionForParent(String? parentId) {
if (parentId != null) {
return NewTabPosition.first;
}
return ref.read(generalSettingsWithDefaultsProvider).newTabPosition;
}
Future<String?> _resolveParentIdForContext({
required String? parentId,
required String? targetContextId,
@@ -132,7 +125,6 @@ class TabRepository extends _$TabRepository {
final effectiveContextId = tabMode is IsolatedTabMode
? effectiveIsolationContextId
: assignedContainer?.metadata.contextualIdentity;
final newTabPosition = _newTabPositionForParent(validatedParentId);
final newTabId = await tabDao.upsertTabTransactional(
() {
@@ -150,7 +142,6 @@ class TabRepository extends _$TabRepository {
);
},
parentId: Value(validatedParentId),
newTabPosition: newTabPosition,
containerId: Value(assignedContainer?.id),
url: Value(url),
tabMode: Value(tabMode),
@@ -215,7 +206,6 @@ class TabRepository extends _$TabRepository {
tabId,
parentId: Value(validatedParentId),
source: TabSource.manual,
newTabPosition: _newTabPositionForParent(validatedParentId),
containerId: Value(assignedContainer?.id),
url: Value(Uri.tryParse(tab.url)),
tabMode: Value(
@@ -257,6 +247,22 @@ class TabRepository extends _$TabRepository {
? duplicateIsolationContextId
: containerData?.metadata.contextualIdentity;
// Place the duplicate as a sibling of the source — same parent — and
// insert it right after the source's full subtree, so existing
// children of the source are not split from their parent.
final sourceData = await tabDao
.getTabDataById(selectTabId)
.getSingleOrNull();
final sourceParentId = sourceData?.parentId;
final anchorTabId =
await tabDao
.lastSubtreeTabIdByOrderKey(
selectTabId,
containerId: containerData?.id,
)
.getSingleOrNull() ??
selectTabId;
return await tabDao.upsertTabTransactional(
() {
return _tabsService.duplicateTab(
@@ -265,8 +271,8 @@ class TabRepository extends _$TabRepository {
selectNewTab: selectTab,
);
},
parentId: const Value.absent(),
newTabPosition: _newTabPositionForParent(null),
parentId: Value(sourceParentId),
afterTabId: Value(anchorTabId),
containerId: Value(containerData?.id),
tabMode: Value(duplicateTabMode),
);
@@ -319,15 +325,12 @@ class TabRepository extends _$TabRepository {
String? containerId,
bool skipContainerCheck = true,
}) async {
final previousTabId = await ref
.read(tabDatabaseProvider)
.definitionsDrift
.previousTabByOrderKey(
tabId: tabId,
containerId: containerId,
skipContainerCheck: skipContainerCheck,
)
.getSingleOrNull();
final previousTabId = await _adjacentVisibleTabByOrder(
tabId,
containerId: containerId,
skipContainerCheck: skipContainerCheck,
selectPrevious: true,
);
if (ref.mounted && previousTabId != null) {
return selectTab(previousTabId);
@@ -341,15 +344,12 @@ class TabRepository extends _$TabRepository {
String? containerId,
bool skipContainerCheck = true,
}) async {
final previousTabId = await ref
.read(tabDatabaseProvider)
.definitionsDrift
.nextTabByOrderKey(
tabId: tabId,
containerId: containerId,
skipContainerCheck: skipContainerCheck,
)
.getSingleOrNull();
final previousTabId = await _adjacentVisibleTabByOrder(
tabId,
containerId: containerId,
skipContainerCheck: skipContainerCheck,
selectPrevious: false,
);
if (ref.mounted && previousTabId != null) {
return selectTab(previousTabId);
@@ -383,7 +383,80 @@ class TabRepository extends _$TabRepository {
return true;
}
Future<void> _selectNextTab(String tabId) async {
Future<String?> _adjacentVisibleTabByOrder(
String tabId, {
required String? containerId,
required bool skipContainerCheck,
required bool selectPrevious,
}) {
// "Previous/next" here is interpreted relative to the *tab bar*
// direction, even when the user triggered the navigation from the tab
// tray (which has its own `tabListDirection`). If the two settings
// disagree, "next tab" while looking at the tray flows by tab-bar
// direction. Treat as intentional — keyboard / gesture navigation is
// anchored to the bar's mental model.
final newestFirst =
ref.read(generalSettingsWithDefaultsProvider).tabBarDirection ==
TabBarDirection.newestFirst;
final TabDatabase tabDatabase = ref.read(tabDatabaseProvider);
final definitions = tabDatabase.definitionsDrift;
if (newestFirst == selectPrevious) {
return definitions
.nextTabByOrderKey(
tabId: tabId,
containerId: containerId,
skipContainerCheck: skipContainerCheck,
)
.getSingleOrNull();
}
return definitions
.previousTabByOrderKey(
tabId: tabId,
containerId: containerId,
skipContainerCheck: skipContainerCheck,
)
.getSingleOrNull();
}
Future<String?> _nearestAvailableVisibleTabByOrder(
String tabId, {
required String? containerId,
required Set<String> excludedTabIds,
}) async {
Future<String?> walkDirection({required bool selectPrevious}) async {
var candidate = await _adjacentVisibleTabByOrder(
tabId,
containerId: containerId,
skipContainerCheck: false,
selectPrevious: selectPrevious,
);
while (candidate != null) {
if (!excludedTabIds.contains(candidate)) {
return candidate;
}
candidate = await _adjacentVisibleTabByOrder(
candidate,
containerId: containerId,
skipContainerCheck: false,
selectPrevious: selectPrevious,
);
}
return null;
}
return await walkDirection(selectPrevious: true) ??
await walkDirection(selectPrevious: false);
}
Future<void> _selectNextTab(
String tabId, {
Set<String> excludedTabIds = const {},
}) async {
final tabState = ref.read(tabStatesProvider)[tabId];
final currentContainerId = await ref
@@ -395,13 +468,20 @@ class TabRepository extends _$TabRepository {
final sameContainerTabs = await ref
.read(containerRepositoryProvider.notifier)
.getContainerTabIds(currentContainerId)
.then((tabs) => tabs.where((tab) => tab != tabId).toList());
.then(
(tabs) => tabs
.where((tab) => tab != tabId && !excludedTabIds.contains(tab))
.toList(),
);
if (!ref.mounted) return;
// Priority 1: Check for parent tab first
if (tabState?.parentId != null) {
return _tabsService.selectTab(tabId: tabState!.parentId!);
final parentId = tabState!.parentId!;
if (!excludedTabIds.contains(parentId)) {
return _tabsService.selectTab(tabId: parentId);
}
}
// Priority 2: Check for previous tab by timestamp
@@ -419,34 +499,14 @@ class TabRepository extends _$TabRepository {
if (!ref.mounted) return;
final previousOrderedTabId = await ref
.read(tabDatabaseProvider)
.definitionsDrift
.previousTabByOrderKey(
tabId: tabId,
containerId: currentContainerId,
skipContainerCheck: false,
)
.getSingleOrNull();
final orderedNeighborTabId = await _nearestAvailableVisibleTabByOrder(
tabId,
containerId: currentContainerId,
excludedTabIds: excludedTabIds,
);
if (previousOrderedTabId != null) {
return _tabsService.selectTab(tabId: previousOrderedTabId);
}
if (!ref.mounted) return;
final nextOrderedTabId = await ref
.read(tabDatabaseProvider)
.definitionsDrift
.nextTabByOrderKey(
tabId: tabId,
containerId: currentContainerId,
skipContainerCheck: false,
)
.getSingleOrNull();
if (nextOrderedTabId != null) {
return _tabsService.selectTab(tabId: nextOrderedTabId);
if (orderedNeighborTabId != null) {
return _tabsService.selectTab(tabId: orderedNeighborTabId);
}
if (!ref.mounted) return;
@@ -454,7 +514,11 @@ class TabRepository extends _$TabRepository {
final unassignedTabs = await ref
.read(containerRepositoryProvider.notifier)
.getContainerTabIds(null)
.then((tabs) => tabs.where((tab) => tab != tabId).toList());
.then(
(tabs) => tabs
.where((tab) => tab != tabId && !excludedTabIds.contains(tab))
.toList(),
);
if (unassignedTabs.isNotEmpty) {
return _tabsService.selectTab(tabId: unassignedTabs.first);
@@ -474,7 +538,11 @@ class TabRepository extends _$TabRepository {
(container) => ref
.read(containerRepositoryProvider.notifier)
.getContainerTabIds(container.id)
.then((tabs) => tabs.where((tab) => tab != tabId).toList()),
.then(
(tabs) => tabs
.where((tab) => tab != tabId && !excludedTabIds.contains(tab))
.toList(),
),
);
if (nextContainerTabs.isNotEmpty) {
@@ -493,6 +561,8 @@ class TabRepository extends _$TabRepository {
await _selectNextTab(tabId);
}
await _preservePromotedChildOrderOnClose([tabId]);
await _tabsService.removeTab(tabId: tabId);
// Queue isolation cleanup — actual cleanup runs after syncTabs
@@ -517,13 +587,22 @@ class TabRepository extends _$TabRepository {
final selectedTab = ref.read(selectedTabProvider);
if (selectedTab.mapNotNull(tabIds.contains) ?? false) {
await _selectNextTab(selectedTab!);
await _selectNextTab(selectedTab!, excludedTabIds: tabIds.toSet());
}
await _preservePromotedChildOrderOnClose(tabIds);
await _tabsService.removeTabs(ids: tabIds);
});
}
Future<void> _preservePromotedChildOrderOnClose(List<String> tabIds) {
return ref
.read(tabDatabaseProvider)
.tabDao
.preservePromotedChildOrderOnClose(tabIds);
}
/// Clears Gecko browsing data and removes proxy alias for an isolation
/// context if no more tabs share it.
Future<void> _cleanupIsolationContextIfEmpty(String contextId) async {
@@ -638,7 +717,6 @@ class TabRepository extends _$TabRepository {
tabId,
parentId: const Value.absent(),
source: TabSource.addedEvent,
newTabPosition: _newTabPositionForParent(null),
containerId: Value(containerId),
);
},
@@ -41,7 +41,7 @@ final class TabRepositoryProvider
}
}
String _$tabRepositoryHash() => r'ce4ac2f2efbb859ba66e98c6e6d21f8205d9b4ed';
String _$tabRepositoryHash() => r'4c5bda4d0ddcc66cfa420d3c2ca97db42b73b878';
abstract class _$TabRepository extends $Notifier<void> {
void build();
@@ -86,6 +86,8 @@ class TabViewFilterOptions with FastEquatable {
final TabTypeFilter tabTypeFilter;
final TabSortType sortType;
final bool sortPinnedFirst;
@JsonKey(defaultValue: true)
final bool showHierarchicalTabs;
@DateTimeRangeConverter()
final DateTimeRange<DateTime>? dateRange;
final TabQuickInterval? quickInterval;
@@ -94,6 +96,7 @@ class TabViewFilterOptions with FastEquatable {
required this.tabTypeFilter,
required this.sortType,
required this.sortPinnedFirst,
required this.showHierarchicalTabs,
required this.dateRange,
required this.quickInterval,
});
@@ -103,6 +106,7 @@ class TabViewFilterOptions with FastEquatable {
tabTypeFilter: TabTypeFilter.all,
sortType: TabSortType.manual,
sortPinnedFirst: true,
showHierarchicalTabs: true,
dateRange: null,
quickInterval: null,
);
@@ -132,6 +136,7 @@ class TabViewFilterOptions with FastEquatable {
tabTypeFilter,
sortType,
sortPinnedFirst,
showHierarchicalTabs,
dateRange,
quickInterval,
];
@@ -13,6 +13,8 @@ abstract class _$TabViewFilterOptionsCWProxy {
TabViewFilterOptions sortPinnedFirst(bool sortPinnedFirst);
TabViewFilterOptions showHierarchicalTabs(bool showHierarchicalTabs);
TabViewFilterOptions dateRange(DateTimeRange<DateTime>? dateRange);
TabViewFilterOptions quickInterval(TabQuickInterval? quickInterval);
@@ -28,6 +30,7 @@ abstract class _$TabViewFilterOptionsCWProxy {
TabTypeFilter tabTypeFilter,
TabSortType sortType,
bool sortPinnedFirst,
bool showHierarchicalTabs,
DateTimeRange<DateTime>? dateRange,
TabQuickInterval? quickInterval,
});
@@ -53,6 +56,10 @@ class _$TabViewFilterOptionsCWProxyImpl
TabViewFilterOptions sortPinnedFirst(bool sortPinnedFirst) =>
call(sortPinnedFirst: sortPinnedFirst);
@override
TabViewFilterOptions showHierarchicalTabs(bool showHierarchicalTabs) =>
call(showHierarchicalTabs: showHierarchicalTabs);
@override
TabViewFilterOptions dateRange(DateTimeRange<DateTime>? dateRange) =>
call(dateRange: dateRange);
@@ -73,6 +80,7 @@ class _$TabViewFilterOptionsCWProxyImpl
Object? tabTypeFilter = const $CopyWithPlaceholder(),
Object? sortType = const $CopyWithPlaceholder(),
Object? sortPinnedFirst = const $CopyWithPlaceholder(),
Object? showHierarchicalTabs = const $CopyWithPlaceholder(),
Object? dateRange = const $CopyWithPlaceholder(),
Object? quickInterval = const $CopyWithPlaceholder(),
}) {
@@ -92,6 +100,12 @@ class _$TabViewFilterOptionsCWProxyImpl
? _value.sortPinnedFirst
// ignore: cast_nullable_to_non_nullable
: sortPinnedFirst as bool,
showHierarchicalTabs:
showHierarchicalTabs == const $CopyWithPlaceholder() ||
showHierarchicalTabs == null
? _value.showHierarchicalTabs
// ignore: cast_nullable_to_non_nullable
: showHierarchicalTabs as bool,
dateRange: dateRange == const $CopyWithPlaceholder()
? _value.dateRange
// ignore: cast_nullable_to_non_nullable
@@ -122,6 +136,7 @@ TabViewFilterOptions _$TabViewFilterOptionsFromJson(
tabTypeFilter: $enumDecode(_$TabTypeFilterEnumMap, json['tabTypeFilter']),
sortType: $enumDecode(_$TabSortTypeEnumMap, json['sortType']),
sortPinnedFirst: json['sortPinnedFirst'] as bool,
showHierarchicalTabs: json['showHierarchicalTabs'] as bool? ?? true,
dateRange: const DateTimeRangeConverter().fromJson(
json['dateRange'] as Map<String, dynamic>?,
),
@@ -137,6 +152,7 @@ Map<String, dynamic> _$TabViewFilterOptionsToJson(
'tabTypeFilter': _$TabTypeFilterEnumMap[instance.tabTypeFilter]!,
'sortType': _$TabSortTypeEnumMap[instance.sortType]!,
'sortPinnedFirst': instance.sortPinnedFirst,
'showHierarchicalTabs': instance.showHierarchicalTabs,
'dateRange': const DateTimeRangeConverter().toJson(instance.dateRange),
'quickInterval': _$TabQuickIntervalEnumMap[instance.quickInterval],
};
@@ -35,6 +35,7 @@ import 'package:weblibre/features/geckoview/features/browser/domain/entities/tab
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart';
import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart';
import 'package:weblibre/features/geckoview/features/search/domain/entities/tab_preview.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_entity.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
@@ -59,7 +60,13 @@ bool canManualTabReorder(Ref ref) {
).select((value) => (value.value?.query ?? '').isNotEmpty),
);
return !filterOptions.hasActiveFilter && !hasActiveSearch;
// sortPinnedFirst partitions the rendered list into pinned/unpinned
// sections that don't reflect storage order_key ordering. A drag would
// compute anchors across the partition boundary and silently snap the
// moved tab into the wrong section once the stream re-renders.
return !filterOptions.hasActiveFilter &&
!hasActiveSearch &&
!filterOptions.sortPinnedFirst;
}
@Riverpod(keepAlive: true)
@@ -239,6 +246,10 @@ selectedContainerTabStatesWithContainer(Ref ref) {
),
];
final tabBarDirection = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.tabBarDirection),
);
items.sort((a, b) {
final aPinned = pinnedTabIds?.contains(a.$1.id) ?? false;
final bPinned = pinnedTabIds?.contains(b.$1.id) ?? false;
@@ -249,7 +260,10 @@ selectedContainerTabStatesWithContainer(Ref ref) {
final aOrderKey = orderKeys[a.$1.id] ?? '';
final bOrderKey = orderKeys[b.$1.id] ?? '';
return aOrderKey.compareTo(bOrderKey);
// Root tabs always append (trailing key), so ascending = oldest first.
return tabBarDirection == TabBarDirection.newestFirst
? bOrderKey.compareTo(aOrderKey)
: aOrderKey.compareTo(bOrderKey);
});
return EquatableValue(items);
@@ -273,9 +287,23 @@ EquatableValue<List<TabStateWithContainer>> quickTabSwitcherTabStates(
ref.watch(selectedContainerTabStatesWithContainerProvider).value,
};
// `containerTabs` already had `tabBarDirection` applied during its sort.
// For `lastUsedTabs` the upstream `fifoTabStates` is MRU-first (timestamp
// desc); honour the same direction setting here so the user's choice
// takes effect in the default switcher mode too.
final tabBarDirection = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.tabBarDirection),
);
return EquatableValue(switch (effectiveMode) {
QuickTabSwitcherMode.lastUsedTabs =>
tabStates.where((state) => state.$1.id != selectedTabId).toList(),
QuickTabSwitcherMode.lastUsedTabs => () {
final filtered = tabStates
.where((state) => state.$1.id != selectedTabId)
.toList();
return tabBarDirection == TabBarDirection.oldestFirst
? filtered.reversed.toList()
: filtered;
}(),
QuickTabSwitcherMode.containerTabs => tabStates,
});
}
@@ -587,11 +615,24 @@ EquatableValue<List<TabEntity>> seamlessFilteredTabEntities(
? ref.watch(watchTabTimestampsProvider.select((value) => value.value))
: null;
final tabListDirection = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.tabListDirection),
);
// Root tabs are always inserted with a trailing LexoRank key, so the
// database order (ascending order_key) is oldest-first. Flip to
// newest-first when the user selects that direction.
List<TabEntity> applyDirection(List<TabEntity> entities) {
return tabListDirection == TabListDirection.newestFirst
? entities.reversed.toList()
: entities;
}
if (tabSearchResults == null) {
if (filterOptions.hasActiveFilter || pinnedTabIds.isNotEmpty) {
return EquatableValue(
_applyTabFiltersAndSort(
availableTabs.value,
applyDirection(availableTabs.value),
filterOptions,
tabStates,
pinnedTabIds,
@@ -600,7 +641,7 @@ EquatableValue<List<TabEntity>> seamlessFilteredTabEntities(
);
}
return availableTabs;
return EquatableValue(applyDirection(availableTabs.value));
}
final searchFiltered = tabSearchResults
@@ -624,6 +665,7 @@ EquatableValue<List<TabEntity>> seamlessFilteredTabEntities(
}
return EquatableValue(searchFiltered);
// Search results retain the search-relevance ordering — no direction flip.
}
@Riverpod()
@@ -671,6 +713,345 @@ EquatableValue<List<TabPreview>> filteredTabPreviews(
);
}
/// Grouped flat-list rendering for the list and grid views.
///
/// Parent rows always render before their descendants. [TabListDirection]
/// applies both to root group ordering and to sibling ordering below each
/// parent, so parent-child pairs stay together while child order still follows
/// the configured direction.
///
/// Returns `null` when the input data is not yet available (loading).
@Riverpod()
EquatableValue<List<TabListItemEntity>> groupedTabListItems(
Ref ref, {
required String? containerId,
}) {
final tabsWithRoot = ref.watch(
watchTabsWithRootAndDepthProvider(
containerId,
).select((value) => value.value),
);
if (tabsWithRoot == null) {
return EquatableValue(const []);
}
final tabList = ref.watch(tabListProvider);
final tabStates = ref.watch(tabStatesProvider);
final filterOptions = ref.watch(tabViewFilterControllerProvider);
final pinnedTabIds = ref.watch(
watchPinnedTabIdsProvider.select(
(value) => value.value ?? const <String>{},
),
);
final tabListDirection = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.tabListDirection),
);
final collapsedGroups = ref.watch(collapsedGroupsProvider);
final needsTimestamps =
filterOptions.effectiveDateRange != null ||
filterOptions.sortType.sortField == SortField.dateAsc ||
filterOptions.sortType.sortField == SortField.dateDesc;
final tabTimestamps = needsTimestamps
? ref.watch(watchTabTimestampsProvider.select((value) => value.value))
: null;
// Index every row from the CTE — we need the full graph to walk ancestor
// chains even when intermediate ancestors fail the filter.
final byId = {for (final row in tabsWithRoot) row.id: row};
// Filter to tabs that exist in the engine session list and pass the
// tab-type / date-range filter. Filtering is applied to *individual* tabs.
final available = tabsWithRoot
.where((row) => tabList.value.contains(row.id))
.where(
(row) => filterOptions.matchesTab(
tabStates[row.id]?.tabMode,
tabTimestamps?[row.id],
),
)
.toList();
if (available.isEmpty) {
return EquatableValue(const []);
}
final visibleIds = {for (final row in available) row.id};
// Map every visible row to the closest visible ancestor (its effective
// root). When the original root is filtered out we walk up the chain via
// parent_id until we either find a visible ancestor or fall off the top —
// in the latter case the row becomes its own root. This keeps subtrees
// grouped under whichever ancestor remains visible.
String effectiveRootFor(TabsWithRootAndDepthResult row) {
var effectiveRoot = row;
var current = row;
while (true) {
final parentId = current.parentId;
if (parentId == null) return effectiveRoot.id;
final parent = byId[parentId];
if (parent == null) return effectiveRoot.id;
if (visibleIds.contains(parent.id)) {
// Climb to the topmost visible ancestor so siblings collapse into a
// single group rather than fragmenting.
effectiveRoot = parent;
current = parent;
continue;
}
// Skip filtered-out ancestor and keep climbing.
current = parent;
}
}
// Recompute depth relative to the effective root (so indentation stays
// sensible after filtered-out ancestors collapse out).
int depthFromRoot(TabsWithRootAndDepthResult row, String rootId) {
var depth = 0;
var current = row;
while (current.id != rootId) {
final parentId = current.parentId;
if (parentId == null) return depth;
final parent = byId[parentId];
if (parent == null) return depth;
if (visibleIds.contains(parent.id)) {
depth++;
}
current = parent;
}
return depth;
}
final byRoot = <String, List<_GroupedRow>>{};
for (final row in available) {
final rootId = effectiveRootFor(row);
byRoot
.putIfAbsent(rootId, () => [])
.add(_GroupedRow(row: row, depth: depthFromRoot(row, rootId)));
}
// Build a list of group records to sort across.
final sortField = filterOptions.sortType.sortField;
final groupRecords = <_TabGroupRecord>[];
for (final entry in byRoot.entries) {
final rootMember = entry.value.firstWhere((r) => r.row.id == entry.key);
final root = rootMember.row;
final state = tabStates[root.id];
final timestamp = tabTimestamps?[root.id];
groupRecords.add(
_TabGroupRecord(
rootId: root.id,
rootOrderKey: root.orderKey,
root: rootMember,
members: entry.value,
isPinned: pinnedTabIds.contains(root.id),
titleKey:
sortField == SortField.titleAsc || sortField == SortField.titleDesc
? (state?.titleOrAuthority ?? '').toLowerCase()
: null,
urlKey: sortField == SortField.urlAsc || sortField == SortField.urlDesc
? (state?.url.toString() ?? '')
: null,
dateKey:
sortField == SortField.dateAsc || sortField == SortField.dateDesc
? (timestamp ?? DateTime(0))
: null,
),
);
}
groupRecords.sort((a, b) {
if (filterOptions.sortPinnedFirst && a.isPinned != b.isPinned) {
return a.isPinned ? -1 : 1;
}
if (sortField != null) {
final cmp = switch (sortField) {
SortField.titleAsc => a.titleKey!.compareTo(b.titleKey!),
SortField.titleDesc => b.titleKey!.compareTo(a.titleKey!),
SortField.urlAsc => a.urlKey!.compareTo(b.urlKey!),
SortField.urlDesc => b.urlKey!.compareTo(a.urlKey!),
SortField.dateAsc => a.dateKey!.compareTo(b.dateKey!),
SortField.dateDesc => b.dateKey!.compareTo(a.dateKey!),
};
if (cmp != 0) return cmp;
}
return a.rootOrderKey.compareTo(b.rootOrderKey);
});
// Direction applies to the order of root groups here. Sibling order below
// each parent is handled during recursive flattening.
// When no explicit sortField is active, oldest-first is the natural
// order_key ASC; newest-first reverses the group list. Pinned and
// unpinned partitions are reversed independently so pinned-first stays
// intact while still flipping the relative order within each partition.
if (sortField == null && tabListDirection == TabListDirection.newestFirst) {
if (filterOptions.sortPinnedFirst) {
final pinned = groupRecords.where((g) => g.isPinned).toList()
..sort((a, b) => b.rootOrderKey.compareTo(a.rootOrderKey));
final unpinned = groupRecords.where((g) => !g.isPinned).toList()
..sort((a, b) => b.rootOrderKey.compareTo(a.rootOrderKey));
groupRecords
..clear()
..addAll(pinned)
..addAll(unpinned);
} else {
final reversed = groupRecords.reversed.toList();
groupRecords
..clear()
..addAll(reversed);
}
}
// Flatten according to expansion state.
final result = <TabListItemEntity>[];
for (final group in groupRecords) {
if (group.members.length == 1) {
final only = group.root.row;
result.add(
TabListStandaloneItem(
tabId: only.id,
orderKey: only.orderKey,
containerId: containerId,
),
);
continue;
}
final root = group.root.row;
result.add(
TabListParentGroup(
tabId: root.id,
orderKey: root.orderKey,
containerId: containerId,
childCount: group.members.length - 1,
),
);
if (collapsedGroups.contains(root.id)) {
continue;
}
final childrenByVisibleParent = <String, List<_GroupedRow>>{};
for (final member in group.members) {
if (member.row.id == root.id) {
continue;
}
final visibleParentId = _nearestVisibleParentId(
member.row,
root.id,
byId,
visibleIds,
);
childrenByVisibleParent
.putIfAbsent(visibleParentId, () => [])
.add(member);
}
// Children are always sorted by storage `order_key` (optionally reversed
// for newest-first) — even when an explicit `sortField` (title/url/date)
// is active. The explicit sort applies only to root groups; descendants
// remain in insertion order so a parent's children stay grouped in the
// order they were opened rather than alphabetically interleaving across
// siblings.
for (final children in childrenByVisibleParent.values) {
children.sort((a, b) {
final cmp = a.row.orderKey.compareTo(b.row.orderKey);
return tabListDirection == TabListDirection.newestFirst ? -cmp : cmp;
});
}
void addChildren(String parentId) {
for (final member
in childrenByVisibleParent[parentId] ?? const <_GroupedRow>[]) {
final child = member.row;
final grandchildren =
childrenByVisibleParent[child.id] ?? const <_GroupedRow>[];
result.add(
TabListChildItem(
tabId: child.id,
orderKey: child.orderKey,
containerId: containerId,
parentId: parentId,
rootId: root.id,
depth: member.depth,
childCount: grandchildren.length,
),
);
// Respect per-node collapse: collapsing an intermediate child hides
// its descendants while keeping siblings of the parent visible.
if (!collapsedGroups.contains(child.id)) {
addChildren(child.id);
}
}
}
addChildren(root.id);
}
return EquatableValue(result);
}
String _nearestVisibleParentId(
TabsWithRootAndDepthResult row,
String rootId,
Map<String, TabsWithRootAndDepthResult> byId,
Set<String> visibleIds,
) {
var current = row;
final seen = <String>{row.id};
while (true) {
final parentId = current.parentId;
if (parentId == null) {
return rootId;
}
if (parentId == rootId) {
return rootId;
}
final parent = byId[parentId];
if (parent == null || !seen.add(parent.id)) {
return rootId;
}
if (visibleIds.contains(parent.id)) {
return parent.id;
}
current = parent;
}
}
class _GroupedRow {
final TabsWithRootAndDepthResult row;
final int depth;
_GroupedRow({required this.row, required this.depth});
}
class _TabGroupRecord {
final String rootId;
final String rootOrderKey;
final _GroupedRow root;
final List<_GroupedRow> members;
final bool isPinned;
final String? titleKey;
final String? urlKey;
final DateTime? dateKey;
_TabGroupRecord({
required this.rootId,
required this.rootOrderKey,
required this.root,
required this.members,
required this.isPinned,
required this.titleKey,
required this.urlKey,
required this.dateKey,
});
}
@Riverpod()
class AppLinksModeNotifier extends _$AppLinksModeNotifier {
final _service = GeckoEngineSettingsService();
@@ -49,7 +49,7 @@ final class CanManualTabReorderProvider
}
String _$canManualTabReorderHash() =>
r'ba5d961933464b6e005d7945802908a9a4ae034b';
r'598bc67a750f45893ba555b9b7866499b1808896';
@ProviderFor(SelectedBangTrigger)
final selectedBangTriggerProvider = SelectedBangTriggerFamily._();
@@ -529,7 +529,7 @@ final class SelectedContainerTabStatesWithContainerProvider
}
String _$selectedContainerTabStatesWithContainerHash() =>
r'187e408857ddc5fe08f8e5344beb510c3c39abe1';
r'6b630cdfc589af6961d4c96c90d09bd107560f15';
@ProviderFor(quickTabSwitcherTabStates)
final quickTabSwitcherTabStatesProvider = QuickTabSwitcherTabStatesFamily._();
@@ -601,7 +601,7 @@ final class QuickTabSwitcherTabStatesProvider
}
String _$quickTabSwitcherTabStatesHash() =>
r'd83ea34d366238681baaad0b36699ee0742edc3d';
r'32e928b4596ef2388f8c0e4cfdcf8f3563a1e24a';
final class QuickTabSwitcherTabStatesFamily extends $Family
with
@@ -962,7 +962,7 @@ final class SeamlessFilteredTabEntitiesProvider
}
String _$seamlessFilteredTabEntitiesHash() =>
r'bc6833f975e9af3b2117268d7644dc56131c85a3';
r'79abca3ad2753af4482c0e3bcfa317a6f336ca17';
final class SeamlessFilteredTabEntitiesFamily extends $Family
with
@@ -1095,6 +1095,139 @@ final class FilteredTabPreviewsFamily extends $Family
String toString() => r'filteredTabPreviewsProvider';
}
/// Grouped flat-list rendering for the list and grid views.
///
/// Parent rows always render before their descendants. [TabListDirection]
/// applies both to root group ordering and to sibling ordering below each
/// parent, so parent-child pairs stay together while child order still follows
/// the configured direction.
///
/// Returns `null` when the input data is not yet available (loading).
@ProviderFor(groupedTabListItems)
final groupedTabListItemsProvider = GroupedTabListItemsFamily._();
/// Grouped flat-list rendering for the list and grid views.
///
/// Parent rows always render before their descendants. [TabListDirection]
/// applies both to root group ordering and to sibling ordering below each
/// parent, so parent-child pairs stay together while child order still follows
/// the configured direction.
///
/// Returns `null` when the input data is not yet available (loading).
final class GroupedTabListItemsProvider
extends
$FunctionalProvider<
EquatableValue<List<TabListItemEntity>>,
EquatableValue<List<TabListItemEntity>>,
EquatableValue<List<TabListItemEntity>>
>
with $Provider<EquatableValue<List<TabListItemEntity>>> {
/// Grouped flat-list rendering for the list and grid views.
///
/// Parent rows always render before their descendants. [TabListDirection]
/// applies both to root group ordering and to sibling ordering below each
/// parent, so parent-child pairs stay together while child order still follows
/// the configured direction.
///
/// Returns `null` when the input data is not yet available (loading).
GroupedTabListItemsProvider._({
required GroupedTabListItemsFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'groupedTabListItemsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$groupedTabListItemsHash();
@override
String toString() {
return r'groupedTabListItemsProvider'
''
'($argument)';
}
@$internal
@override
$ProviderElement<EquatableValue<List<TabListItemEntity>>> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
EquatableValue<List<TabListItemEntity>> create(Ref ref) {
final argument = this.argument as String?;
return groupedTabListItems(ref, containerId: argument);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(EquatableValue<List<TabListItemEntity>> value) {
return $ProviderOverride(
origin: this,
providerOverride:
$SyncValueProvider<EquatableValue<List<TabListItemEntity>>>(value),
);
}
@override
bool operator ==(Object other) {
return other is GroupedTabListItemsProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$groupedTabListItemsHash() =>
r'48c7c971d95934afb48aa19b7400e208ee2f10e4';
/// Grouped flat-list rendering for the list and grid views.
///
/// Parent rows always render before their descendants. [TabListDirection]
/// applies both to root group ordering and to sibling ordering below each
/// parent, so parent-child pairs stay together while child order still follows
/// the configured direction.
///
/// Returns `null` when the input data is not yet available (loading).
final class GroupedTabListItemsFamily extends $Family
with
$FunctionalFamilyOverride<
EquatableValue<List<TabListItemEntity>>,
String?
> {
GroupedTabListItemsFamily._()
: super(
retry: null,
name: r'groupedTabListItemsProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
/// Grouped flat-list rendering for the list and grid views.
///
/// Parent rows always render before their descendants. [TabListDirection]
/// applies both to root group ordering and to sibling ordering below each
/// parent, so parent-child pairs stay together while child order still follows
/// the configured direction.
///
/// Returns `null` when the input data is not yet available (loading).
GroupedTabListItemsProvider call({required String? containerId}) =>
GroupedTabListItemsProvider._(argument: containerId, from: this);
@override
String toString() => r'groupedTabListItemsProvider';
}
@ProviderFor(AppLinksModeNotifier)
final appLinksModeProvider = AppLinksModeNotifierProvider._();
@@ -50,20 +50,30 @@ class ContextualToolbarConfigRepository {
return _dao.assignFallback(buttonId, fallbackId);
}
Future<String> generateLeadingOrderKey() {
return _dao.generateLeadingOrderKey().getSingle();
Future<String> generateLeadingOrderKey({required bool isVisible}) {
return _dao.generateLeadingOrderKey(isVisible: isVisible).getSingle();
}
Future<String> generateTrailingOrderKey() {
return _dao.generateTrailingOrderKey().getSingle();
Future<String> generateTrailingOrderKey({required bool isVisible}) {
return _dao.generateTrailingOrderKey(isVisible: isVisible).getSingle();
}
Future<String?> generateOrderKeyAfterButtonId(String buttonId) {
return _dao.generateOrderKeyAfterButtonId(buttonId).getSingleOrNull();
Future<String?> generateOrderKeyAfterButtonId(
String buttonId, {
required bool isVisible,
}) {
return _dao
.generateOrderKeyAfterButtonId(buttonId, isVisible: isVisible)
.getSingleOrNull();
}
Future<String> generateOrderKeyBeforeButtonId(String buttonId) {
return _dao.generateOrderKeyBeforeButtonId(buttonId).getSingle();
Future<String> generateOrderKeyBeforeButtonId(
String buttonId, {
required bool isVisible,
}) {
return _dao
.generateOrderKeyBeforeButtonId(buttonId, isVisible: isVisible)
.getSingle();
}
Future<void> seedMissingDefaults() {
@@ -46,6 +46,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.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/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/user/data/models/engine_settings.dart';
import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart';
@@ -673,6 +674,23 @@ class _CloseTabToolbarButton extends HookConsumerWidget {
},
child: const Text('Close from Same Host'),
),
MenuItemButton(
leadingIcon: const Icon(Icons.account_tree),
onPressed: () async {
final tabId = scope.selectedTabId;
if (tabId == null) return;
final descendants = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabDescendants(tabId);
if (!context.mounted) return;
final subtreeIds = descendants.keys.toList();
if (subtreeIds.isNotEmpty) {
await closeTabsWithConfirmation(context, ref, subtreeIds);
}
},
child: const Text('Close Tab and Descendants'),
),
],
child: IconButton(
onPressed: scope.isPreview
@@ -80,6 +80,10 @@ class TabViewFilterController extends _$TabViewFilterController {
state = state.copyWith(sortPinnedFirst: value);
}
void setShowHierarchicalTabs(bool value) {
state = state.copyWith(showHierarchicalTabs: value);
}
void setDateRange(DateTimeRange<DateTime>? range) {
// ignore: avoid_redundant_argument_values
state = state.copyWith(dateRange: range, quickInterval: null);
@@ -105,6 +109,25 @@ class TabViewFilterController extends _$TabViewFilterController {
}
}
/// Tracks which parent groups are *collapsed* in the grouped list/grid views.
///
/// Stored as the collapsed set so groups default to expanded for fresh
/// sessions. In-memory only — group expansion is treated as ephemeral UI
/// state, not a persisted setting.
@Riverpod(keepAlive: true)
class CollapsedGroups extends _$CollapsedGroups {
@override
Set<String> build() => const {};
void toggle(String parentId) {
state = state.contains(parentId)
? (state.toSet()..remove(parentId))
: (state.toSet()..add(parentId));
}
void expandAll() => state = const {};
}
@Riverpod()
class TabsReorderableController extends _$TabsReorderableController {
void toggle() {
@@ -97,7 +97,7 @@ final class TabViewFilterControllerProvider
}
String _$tabViewFilterControllerHash() =>
r'95e8a03d60ebe05e0c5df38f810f951a4fe2ed78';
r'6f5d42761765c0135bf40953c574c17d141ffab2';
@JsonPersist()
abstract class _$TabViewFilterControllerBase
@@ -119,6 +119,80 @@ abstract class _$TabViewFilterControllerBase
}
}
/// Tracks which parent groups are *collapsed* in the grouped list/grid views.
///
/// Stored as the collapsed set so groups default to expanded for fresh
/// sessions. In-memory only — group expansion is treated as ephemeral UI
/// state, not a persisted setting.
@ProviderFor(CollapsedGroups)
final collapsedGroupsProvider = CollapsedGroupsProvider._();
/// Tracks which parent groups are *collapsed* in the grouped list/grid views.
///
/// Stored as the collapsed set so groups default to expanded for fresh
/// sessions. In-memory only — group expansion is treated as ephemeral UI
/// state, not a persisted setting.
final class CollapsedGroupsProvider
extends $NotifierProvider<CollapsedGroups, Set<String>> {
/// Tracks which parent groups are *collapsed* in the grouped list/grid views.
///
/// Stored as the collapsed set so groups default to expanded for fresh
/// sessions. In-memory only — group expansion is treated as ephemeral UI
/// state, not a persisted setting.
CollapsedGroupsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'collapsedGroupsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$collapsedGroupsHash();
@$internal
@override
CollapsedGroups create() => CollapsedGroups();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Set<String> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Set<String>>(value),
);
}
}
String _$collapsedGroupsHash() => r'f2434cc008720240513a41bd11eea6656aeb0c13';
/// Tracks which parent groups are *collapsed* in the grouped list/grid views.
///
/// Stored as the collapsed set so groups default to expanded for fresh
/// sessions. In-memory only — group expansion is treated as ephemeral UI
/// state, not a persisted setting.
abstract class _$CollapsedGroups extends $Notifier<Set<String>> {
Set<String> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<Set<String>, Set<String>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<Set<String>, Set<String>>,
Set<String>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(TabsReorderableController)
final tabsReorderableControllerProvider = TabsReorderableControllerProvider._();
@@ -0,0 +1,395 @@
/*
* 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/logger.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_item.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
class TabViewReorderResult {
final List<String> movingTabIds;
final String? previousTabId;
final String? nextTabId;
const TabViewReorderResult({
required this.movingTabIds,
required this.previousTabId,
required this.nextTabId,
});
}
TabViewReorderResult? buildTabViewReorderResult({
required List<TabViewItem> visibleItems,
required List<TabsWithRootAndDepthResult> treeRows,
required Set<String> collapsedGroups,
required int oldIndex,
required int newIndex,
required TabListDirection tabListDirection,
required bool hierarchical,
}) {
if (oldIndex < 0 || oldIndex >= visibleItems.length) {
logger.t(
'reorder refused: oldIndex $oldIndex out of range '
'(visibleItems.length=${visibleItems.length})',
);
return null;
}
final reordered = visibleItems.toList();
final movingItem = reordered.removeAt(oldIndex);
var insertIndex = newIndex;
if (insertIndex > oldIndex) {
insertIndex -= 1;
}
insertIndex = insertIndex.clamp(0, reordered.length);
reordered.insert(insertIndex, movingItem);
if (!hierarchical) {
final ordered = reordered.map((item) => item.tabId).toList();
return _resultFromOrderedIds(
movingTabIds: [movingItem.tabId],
orderedTabIds: tabListDirection == TabListDirection.newestFirst
// Rendering flips root group order for newest-first; convert the
// display order back to storage order before choosing anchors.
? ordered.reversed.toList()
: ordered,
);
}
final rowsById = {for (final row in treeRows) row.id: row};
final parentById = {for (final row in treeRows) row.id: row.parentId};
// Build the parent → ordered-children index once and reuse for every
// _subtreeIds call below. _subtreeIds is invoked for the moving item plus
// every collapsed group encountered while flattening, so reusing this map
// turns N tree walks into N cheap lookups.
final childrenByParent = _buildChildrenByParent(rowsById, parentById);
final moveBlock = _subtreeIds(movingItem.tabId, rowsById, childrenByParent);
final moveBlockIds = moveBlock.toSet();
final withoutMovingItem = visibleItems.toList()..removeAt(oldIndex);
final targetBeforeId = insertIndex < withoutMovingItem.length
? withoutMovingItem[insertIndex].tabId
: null;
if (targetBeforeId != null && moveBlockIds.contains(targetBeforeId)) {
logger.t(
'reorder refused: drop target is inside the moving subtree',
);
return null;
}
final remaining = [
for (final item in visibleItems)
if (!moveBlockIds.contains(item.tabId)) item,
];
final requestedInsertIndex = targetBeforeId == null
? remaining.length
: remaining.indexWhere((item) => item.tabId == targetBeforeId);
if (requestedInsertIndex < 0) {
logger.t(
'reorder refused: target $targetBeforeId not present in remaining list '
'(defensive)',
);
return null;
}
final parentScope = _parentScope(movingItem);
final resolvedInsertIndex = _resolveInsertIndexInParentScope(
remaining,
requestedInsertIndex,
parentScope,
parentById,
);
if (resolvedInsertIndex == null) {
logger.t(
'reorder snapped: no anchor for parent scope $parentScope — drop '
'rejected so the moving tab stays in its original parent',
);
return null;
}
final reorderedBlocks = remaining.toList()
..insert(resolvedInsertIndex, movingItem);
final displayBlocks = <List<String>>[];
final emitted = <String>{};
for (final item in reorderedBlocks) {
if (emitted.contains(item.tabId)) {
continue;
}
final hasChildren =
item.parentGroup != null || (item.childItem?.childCount ?? 0) > 0;
final block = item.tabId == movingItem.tabId
? moveBlock
: hasChildren && collapsedGroups.contains(item.tabId)
? _subtreeIds(item.tabId, rowsById, childrenByParent)
: [item.tabId];
final visibleBlock = [
for (final tabId in block)
if (!emitted.contains(tabId)) tabId,
];
if (visibleBlock.isEmpty) {
continue;
}
displayBlocks.add(visibleBlock);
emitted.addAll(visibleBlock);
}
// Partition blocks (not raw ids) by their root group, preserving block
// atomicity. The first block in each group always contains the root, since
// visibleItems is rendered parent-before-children. Subsequent blocks are
// siblings/descendants in display order.
final blocksByRoot = <String, List<List<String>>>{};
final rootOrder = <String>[];
for (final block in displayBlocks) {
final rootId = _rootIdFor(block.first, parentById);
blocksByRoot
.putIfAbsent(rootId, () {
rootOrder.add(rootId);
return [];
})
.add(block);
}
// For newest-first display, the rendered child order within a group is the
// reverse of storage (orderKey-ascending) order. Convert back to storage
// order before picking anchors so genBetween receives prev.orderKey <
// next.orderKey. Block contents (e.g. the moving subtree from _subtreeIds)
// are already storage-ordered internally, so we reverse blocks as units.
if (tabListDirection == TabListDirection.newestFirst) {
for (final blocks in blocksByRoot.values) {
if (blocks.length > 1) {
final reversedTail = blocks.sublist(1).reversed.toList();
blocks
..removeRange(1, blocks.length)
..addAll(reversedTail);
}
}
}
final orderedRootIds = tabListDirection == TabListDirection.newestFirst
? rootOrder.reversed
: rootOrder;
final orderedTabIds = [
for (final rootId in orderedRootIds)
for (final block in blocksByRoot[rootId]!) ...block,
];
return _resultFromOrderedIds(
movingTabIds: moveBlock,
orderedTabIds: orderedTabIds,
);
}
TabViewReorderResult? _resultFromOrderedIds({
required List<String> movingTabIds,
required List<String> orderedTabIds,
}) {
if (movingTabIds.isEmpty) {
return null;
}
final movingSet = movingTabIds.toSet();
final firstIndex = orderedTabIds.indexWhere(movingSet.contains);
if (firstIndex < 0) {
return null;
}
var lastIndex = firstIndex;
while (lastIndex + 1 < orderedTabIds.length &&
movingSet.contains(orderedTabIds[lastIndex + 1])) {
lastIndex++;
}
return TabViewReorderResult(
movingTabIds: movingTabIds,
previousTabId: firstIndex > 0 ? orderedTabIds[firstIndex - 1] : null,
nextTabId: lastIndex + 1 < orderedTabIds.length
? orderedTabIds[lastIndex + 1]
: null,
);
}
String? _parentScope(TabViewItem item) => item.childItem?.parentId;
int? _resolveInsertIndexInParentScope(
List<TabViewItem> items,
int requestedInsertIndex,
String? parentScope,
Map<String, String?> parentById,
) {
final target = requestedInsertIndex < items.length
? items[requestedInsertIndex]
: null;
if (target != null && _isDirectScopeAnchor(target, parentScope, parentById)) {
return target.tabId == parentScope
? requestedInsertIndex + 1
: requestedInsertIndex;
}
final previousAnchorIndex = _nearestPreviousScopeAnchorIndex(
items,
requestedInsertIndex - 1,
parentScope,
parentById,
);
if (previousAnchorIndex != null) {
return _indexAfterVisibleSubtree(items, previousAnchorIndex, parentById);
}
return _nearestNextScopeAnchorIndex(
items,
requestedInsertIndex,
parentScope,
parentById,
);
}
bool _isDirectScopeAnchor(
TabViewItem item,
String? parentScope,
Map<String, String?> parentById,
) {
return item.tabId == parentScope || parentById[item.tabId] == parentScope;
}
int? _nearestPreviousScopeAnchorIndex(
List<TabViewItem> items,
int startIndex,
String? parentScope,
Map<String, String?> parentById,
) {
for (var i = startIndex; i >= 0; i--) {
if (_isDirectScopeAnchor(items[i], parentScope, parentById)) {
return i;
}
}
return null;
}
int? _nearestNextScopeAnchorIndex(
List<TabViewItem> items,
int startIndex,
String? parentScope,
Map<String, String?> parentById,
) {
for (var i = startIndex; i < items.length; i++) {
if (_isDirectScopeAnchor(items[i], parentScope, parentById)) {
return items[i].tabId == parentScope ? i + 1 : i;
}
}
return null;
}
int _indexAfterVisibleSubtree(
List<TabViewItem> items,
int anchorIndex,
Map<String, String?> parentById,
) {
final anchorId = items[anchorIndex].tabId;
var index = anchorIndex + 1;
while (index < items.length &&
_isDescendantOf(items[index].tabId, anchorId, parentById)) {
index++;
}
return index;
}
bool _isDescendantOf(
String tabId,
String ancestorId,
Map<String, String?> parentById,
) {
var parentId = parentById[tabId];
final seen = <String>{tabId};
while (parentId != null) {
if (parentId == ancestorId) {
return true;
}
if (!seen.add(parentId)) {
return false;
}
parentId = parentById[parentId];
}
return false;
}
Map<String, List<TabsWithRootAndDepthResult>> _buildChildrenByParent(
Map<String, TabsWithRootAndDepthResult> rowsById,
Map<String, String?> parentById,
) {
final childrenByParent = <String, List<TabsWithRootAndDepthResult>>{};
for (final row in rowsById.values) {
final parentId = parentById[row.id];
if (parentId == null) continue;
childrenByParent.putIfAbsent(parentId, () => []).add(row);
}
for (final children in childrenByParent.values) {
children.sort((a, b) => a.orderKey.compareTo(b.orderKey));
}
return childrenByParent;
}
List<String> _subtreeIds(
String rootId,
Map<String, TabsWithRootAndDepthResult> rowsById,
Map<String, List<TabsWithRootAndDepthResult>> childrenByParent,
) {
final root = rowsById[rootId];
if (root == null) {
return [rootId];
}
final result = <String>[];
final visited = <String>{};
void collect(String tabId) {
if (!visited.add(tabId)) return;
result.add(tabId);
for (final child
in childrenByParent[tabId] ?? const <TabsWithRootAndDepthResult>[]) {
collect(child.id);
}
}
collect(root.id);
return result;
}
String _rootIdFor(String tabId, Map<String, String?> parentById) {
var rootId = tabId;
var parentId = parentById[rootId];
final seen = <String>{rootId};
while (parentId != null && parentById.containsKey(parentId)) {
if (!seen.add(parentId)) {
break;
}
rootId = parentId;
parentId = parentById[rootId];
}
return rootId;
}
@@ -347,6 +347,24 @@ class _NavigationRow extends HookConsumerWidget {
},
child: const Text('Close from Same Host'),
),
MenuItemButton(
leadingIcon: const Icon(Icons.account_tree),
onPressed: () async {
final descendants = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabDescendants(selectedTabId);
if (!context.mounted) return;
final subtreeIds = descendants.keys.toList();
if (subtreeIds.isNotEmpty) {
await closeTabsWithConfirmation(context, ref, subtreeIds);
}
if (context.mounted) {
Navigator.pop(context);
}
},
child: const Text('Close Tab and Descendants'),
),
],
child: _buildNavIcon(
icon: MdiIcons.tabMinus,
@@ -36,29 +36,66 @@ 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/features/browser/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/utils/tab_view_reorder.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_context_menu_draggable.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_drop_target.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_group_expand_toggle.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_item.dart';
import 'package:weblibre/features/geckoview/features/browser/utils/grid_calculations.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart'
show TabsWithRootAndDepthResult;
import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_entity.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
/// Top-left expand/collapse toggle for grid cells. Returns null for any
/// row that is not a parent — leaf children rely on the bottom-left depth
/// indicator instead.
Widget? _gridGroupToggleFor(TabViewItem row) {
final parentGroup = row.parentGroup;
if (parentGroup != null) {
return TabGroupExpandToggle(
parentId: parentGroup.tabId,
childCount: parentGroup.childCount,
style: TabGroupToggleStyle.grid,
);
}
final child = row.childItem;
if (child != null && child.childCount > 0) {
return TabGroupExpandToggle(
parentId: child.tabId,
childCount: child.childCount,
style: TabGroupToggleStyle.grid,
);
}
return null;
}
int _gridDepthFor(TabViewItem row) => row.childItem?.depth ?? 0;
class _TabDraggable extends HookConsumerWidget {
final TabEntity entity;
final String tabId;
final String? sourceSearchQuery;
final String? suggestedContainerId;
final VoidCallback onClose;
final Widget? groupToggle;
final int depth;
const _TabDraggable({
required this.entity,
required this.tabId,
required this.onClose,
this.sourceSearchQuery,
this.suggestedContainerId,
this.groupToggle,
this.depth = 0,
});
@override
@@ -73,7 +110,7 @@ class _TabDraggable extends HookConsumerWidget {
null => null,
};
return (dragTabId == entity.tabId) ? value : null;
return (dragTabId == tabId) ? value : null;
}),
);
@@ -81,8 +118,8 @@ class _TabDraggable extends HookConsumerWidget {
final tab = useMemoized(() {
return (suggestedContainerId != null)
? SuggestedSingleGridTabPreview(
key: ValueKey(entity.tabId),
tabId: entity.tabId,
key: ValueKey(tabId),
tabId: tabId,
activeTabId: activeTab,
onTap: () async {
final containerData = await ref
@@ -92,24 +129,20 @@ class _TabDraggable extends HookConsumerWidget {
if (containerData != null) {
await ref
.read(tabDataRepositoryProvider.notifier)
.assignContainer(entity.tabId, containerData);
.assignContainer(tabId, containerData);
}
},
)
: SingleGridTabPreview(
key: ValueKey(entity.tabId),
tabId: entity.tabId,
key: ValueKey(tabId),
tabId: tabId,
activeTabId: activeTab,
onClose: onClose,
sourceSearchQuery: switch (entity) {
DefaultTabEntity _ => null,
final SearchResultTabEntity entity => entity.searchQuery,
TabTreeEntity _ => throw UnimplementedError(
'TabTreeEntity not implemented in tab grid view',
),
},
sourceSearchQuery: sourceSearchQuery,
groupToggle: groupToggle,
depth: depth,
);
}, [entity.tabId, activeTab, suggestedContainerId]);
}, [tabId, activeTab, suggestedContainerId, groupToggle, depth]);
return switch (dragData) {
ContainerDropData() => Opacity(
@@ -144,19 +177,71 @@ class _TabGridView extends HookConsumerWidget {
final screenWidth = MediaQuery.of(context).size.width;
final disableAnimations = MediaQuery.disableAnimationsOf(context);
final canManualReorder = ref.watch(canManualTabReorderProvider);
final reorderEnabled = tabsReorderable && canManualReorder;
final containerId = ref.watch(selectedContainerProvider);
final filteredTabEntities = ref.watch(
seamlessFilteredTabEntitiesProvider(
searchPartition: TabSearchPartition.preview,
// ignore: document_ignores using fast equatable
// ignore: provider_parameters
containerFilter: ContainerFilterById(containerId: containerId),
groupTrees: false,
),
final reorderEnabled = tabsReorderable && canManualReorder;
final filterOptions = ref.watch(tabViewFilterControllerProvider);
final showHierarchicalTabs = filterOptions.showHierarchicalTabs;
final tabListDirection = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.tabListDirection),
);
final collapsedGroups = ref.watch(collapsedGroupsProvider);
final List<TabsWithRootAndDepthResult> treeRows = showHierarchicalTabs
? ref.watch(
watchTabsWithRootAndDepthProvider(
containerId,
).select((value) => value.value ?? const []),
)
: const <TabsWithRootAndDepthResult>[];
final hasActiveSearch = ref.watch(
tabSearchRepositoryProvider(
TabSearchPartition.preview,
).select((value) => (value.value?.query ?? '').isNotEmpty),
);
final List<TabViewItem> primaryRows;
if (hasActiveSearch || !showHierarchicalTabs) {
final flat = ref.watch(
seamlessFilteredTabEntitiesProvider(
searchPartition: TabSearchPartition.preview,
// ignore: document_ignores using fast equatable
// ignore: provider_parameters
containerFilter: ContainerFilterById(containerId: containerId),
groupTrees: false,
),
);
primaryRows = [
for (final entity in flat.value)
TabViewItem.search(
tabId: entity.tabId,
sourceSearchQuery: switch (entity) {
DefaultTabEntity _ => null,
final SearchResultTabEntity e => e.searchQuery,
TabTreeEntity _ => null,
},
),
];
} else {
final grouped = ref.watch(
groupedTabListItemsProvider(containerId: containerId),
);
primaryRows = [
for (final item in grouped.value)
switch (item) {
TabListStandaloneItem(:final tabId) => TabViewItem.standalone(
tabId: tabId,
),
final TabListParentGroup g => TabViewItem.parent(
tabId: g.tabId,
parentGroup: g,
),
final TabListChildItem c => TabViewItem.child(
tabId: c.tabId,
childItem: c,
),
},
];
}
final tabSuggestionsEnabled = ref.watch(
persistedBoolProvider(PersistedBoolKey.tabSuggestions),
@@ -167,12 +252,10 @@ class _TabGridView extends HookConsumerWidget {
: EquatableValue(<TabEntity>[]);
final itemCount =
filteredTabEntities.value.length +
primaryRows.length +
//Limit to 3 sugegstions for now
math.min<int>(suggestedTabEntities.value.length, 3);
final displayItemCount = reorderEnabled
? filteredTabEntities.value.length
: itemCount;
final displayItemCount = reorderEnabled ? primaryRows.length : itemCount;
final activeTab = ref.watch(selectedTabProvider);
@@ -205,9 +288,7 @@ class _TabGridView extends HookConsumerWidget {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients && activeTab != null) {
final index = filteredTabEntities.value.indexWhere(
(entity) => entity.tabId == activeTab,
);
final index = primaryRows.indexWhere((row) => row.tabId == activeTab);
if (index > -1) {
final row = index ~/ crossAxisCount;
@@ -273,7 +354,7 @@ class _TabGridView extends HookConsumerWidget {
return widget;
},
suggestedContainerId: containerId,
filteredTabEntities: filteredTabEntities,
primaryRows: primaryRows,
suggestedTabEntities: suggestedTabEntities,
onClose: onClose,
)
@@ -294,57 +375,30 @@ class _TabGridView extends HookConsumerWidget {
final oldIndex = positions.first.oldIndex;
final newIndex = positions.first.newIndex;
final containerRepository = ref.read(
containerRepositoryProvider.notifier,
);
//Suggestions are at the end and not reorderable, so skip
if (oldIndex >= filteredTabEntities.value.length) {
if (oldIndex >= primaryRows.length) {
return;
}
final tabId = filteredTabEntities.value[oldIndex].tabId;
final containerId = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabContainerId(tabId);
var targetIndex = newIndex;
if (targetIndex > oldIndex) {
targetIndex -= 1;
}
targetIndex = targetIndex.clamp(
0,
filteredTabEntities.value.length - 1,
final result = buildTabViewReorderResult(
visibleItems: primaryRows,
treeRows: treeRows,
collapsedGroups: collapsedGroups,
oldIndex: oldIndex,
newIndex: newIndex,
tabListDirection: tabListDirection,
hierarchical: showHierarchicalTabs && !hasActiveSearch,
);
final String key;
if (targetIndex <= 0) {
key = await containerRepository.getLeadingOrderKey(
containerId,
);
} else if (targetIndex >=
filteredTabEntities.value.length - 1) {
key = await containerRepository.getTrailingOrderKey(
containerId,
);
} else {
if (targetIndex < oldIndex) {
key = (await containerRepository.getOrderKeyAfterTab(
filteredTabEntities.value[targetIndex - 1].tabId,
containerId,
))!;
} else {
key = await containerRepository.getOrderKeyBeforeTab(
filteredTabEntities.value[targetIndex + 1].tabId,
containerId,
);
}
}
if (result == null) return;
await ref
.read(tabDataRepositoryProvider.notifier)
.assignOrderKey(tabId, key);
.reorderTabs(
movingTabIds: result.movingTabIds,
previousTabId: result.previousTabId,
nextTabId: result.nextTabId,
);
},
childBuilder: (reorderableItemBuilder) {
return _TabGrid(
@@ -371,7 +425,7 @@ class _TabGridView extends HookConsumerWidget {
return reorderableItemBuilder(wrapped, index);
},
suggestedContainerId: containerId,
filteredTabEntities: filteredTabEntities,
primaryRows: primaryRows,
suggestedTabEntities: suggestedTabEntities,
onClose: onClose,
);
@@ -391,7 +445,7 @@ class _TabGrid extends StatelessWidget {
required this.itemCount,
required this.itemBuilder,
required this.suggestedContainerId,
required this.filteredTabEntities,
required this.primaryRows,
required this.suggestedTabEntities,
required this.onClose,
});
@@ -400,7 +454,7 @@ class _TabGrid extends StatelessWidget {
final int itemCount;
final ScrollController? scrollController;
final String? suggestedContainerId;
final EquatableValue<List<TabEntity>> filteredTabEntities;
final List<TabViewItem> primaryRows;
final EquatableValue<List<TabEntity>> suggestedTabEntities;
final Widget Function(Widget, int)? itemBuilder;
final VoidCallback onClose;
@@ -419,35 +473,38 @@ class _TabGrid extends StatelessWidget {
),
itemCount: itemCount,
itemBuilder: (context, index) {
final TabEntity entity;
final String? suggestedId;
if (index < primaryRows.length) {
final row = primaryRows[index];
final tab = CustomDraggable(
key: Key(row.tabId),
data: TabDragData(row.tabId),
child: _TabDraggable(
tabId: row.tabId,
sourceSearchQuery: row.sourceSearchQuery,
onClose: onClose,
groupToggle: _gridGroupToggleFor(row),
depth: _gridDepthFor(row),
),
);
if (index < filteredTabEntities.value.length) {
entity = filteredTabEntities.value[index];
suggestedId = null;
} else {
final suggestedIndex = index - filteredTabEntities.value.length;
entity = suggestedTabEntities.value[suggestedIndex];
suggestedId = suggestedContainerId;
if (itemBuilder == null) {
return TabDropTarget(targetTabId: row.tabId, child: tab);
}
return itemBuilder!(tab, index);
}
final suggestedIndex = index - primaryRows.length;
final entity = suggestedTabEntities.value[suggestedIndex];
final tab = CustomDraggable(
key: Key(
suggestedId != null ? 'suggested_${entity.tabId}' : entity.tabId,
),
data: suggestedId != null ? null : TabDragData(entity.tabId),
key: Key('suggested_${entity.tabId}'),
child: _TabDraggable(
entity: entity,
tabId: entity.tabId,
onClose: onClose,
suggestedContainerId: suggestedId,
suggestedContainerId: suggestedContainerId,
),
);
// Only add DragTarget for non-suggested tabs in non-reorder mode
if (suggestedId == null && itemBuilder == null) {
return TabDropTarget(targetTabId: entity.tabId, child: tab);
}
return (itemBuilder != null) ? itemBuilder!(tab, index) : tab;
},
);
@@ -0,0 +1,133 @@
/*
* 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:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart';
enum TabGroupToggleStyle {
/// Standard `IconButton`, sits inside a list-tile trailing row alongside
/// the close button.
list,
/// 28x28 square button that mirrors the grid cell close button — same
/// surface tint, alpha, radius, and icon size.
grid,
}
class TabGroupExpandToggle extends ConsumerWidget {
final String parentId;
final int childCount;
final TabGroupToggleStyle style;
const TabGroupExpandToggle({
required this.parentId,
required this.childCount,
this.style = TabGroupToggleStyle.list,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isCollapsed = ref.watch(
collapsedGroupsProvider.select((set) => set.contains(parentId)),
);
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final chevron = isCollapsed
? Icons.keyboard_arrow_down_rounded
: Icons.keyboard_arrow_up_rounded;
final tooltip = isCollapsed ? 'Expand group' : 'Collapse group';
void onTap() {
ref.read(collapsedGroupsProvider.notifier).toggle(parentId);
}
return switch (style) {
// Unified pill: count + chevron live inside one Material/InkWell so
// the whole badge is the toggle target.
TabGroupToggleStyle.list => Tooltip(
message: tooltip,
child: Material(
color: scheme.secondaryContainer.withAlpha(204),
borderRadius: BorderRadius.circular(20),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$childCount',
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
color: scheme.onSecondaryContainer,
height: 1.0,
),
),
const SizedBox(width: 4),
Icon(chevron, size: 18, color: scheme.onSecondaryContainer),
],
),
),
),
),
),
// Grid: unified pill with count + chevron — height matches the 28px
// close button at top-right; tint, alpha, and radius mirror it so
// the two corners stay balanced.
TabGroupToggleStyle.grid => Tooltip(
message: tooltip,
child: SizedBox(
height: 28,
child: Material(
color: scheme.surfaceContainerHighest.withAlpha(200),
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$childCount',
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
height: 1.0,
),
),
const SizedBox(width: 2),
Icon(chevron, size: 16, color: scheme.onSurfaceVariant),
],
),
),
),
),
),
),
};
}
}
@@ -35,13 +35,19 @@ 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/features/browser/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/utils/tab_view_reorder.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_context_menu_draggable.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_drop_target.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_group_expand_toggle.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_item.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart'
show TabsWithRootAndDepthResult;
import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_entity.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
@@ -49,17 +55,47 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/ta
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
/// Build the hierarchy toggle injected into [ListTabPreview.groupToggle].
///
/// Returns a [TabGroupExpandToggle] for any node with descendants
/// (root parents AND intermediate children), and null otherwise.
Widget? _listGroupToggleFor(TabViewItem row) {
final parentGroup = row.parentGroup;
if (parentGroup != null) {
return TabGroupExpandToggle(
parentId: parentGroup.tabId,
childCount: parentGroup.childCount,
);
}
final child = row.childItem;
if (child != null && child.childCount > 0) {
return TabGroupExpandToggle(
parentId: child.tabId,
childCount: child.childCount,
);
}
return null;
}
int _depthFor(TabViewItem row) => row.childItem?.depth ?? 0;
class _TabDraggable extends HookConsumerWidget {
final TabEntity entity;
final String tabId;
final String? sourceSearchQuery;
final String? suggestedContainerId;
final VoidCallback onClose;
final double height;
final Widget? groupToggle;
final int depth;
const _TabDraggable({
required this.entity,
required this.tabId,
required this.onClose,
required this.height,
this.sourceSearchQuery,
this.suggestedContainerId,
this.groupToggle,
this.depth = 0,
});
@override
@@ -75,7 +111,7 @@ class _TabDraggable extends HookConsumerWidget {
null => null,
};
return (dragTabId == entity.tabId) ? value : null;
return (dragTabId == tabId) ? value : null;
}),
);
@@ -83,8 +119,8 @@ class _TabDraggable extends HookConsumerWidget {
final tab = useMemoized(() {
return (suggestedContainerId != null)
? SuggestedSingleListTabPreview(
key: ValueKey(entity.tabId),
tabId: entity.tabId,
key: ValueKey(tabId),
tabId: tabId,
activeTabId: activeTab,
onTap: () async {
final containerData = await ref
@@ -94,24 +130,20 @@ class _TabDraggable extends HookConsumerWidget {
if (containerData != null) {
await ref
.read(tabDataRepositoryProvider.notifier)
.assignContainer(entity.tabId, containerData);
.assignContainer(tabId, containerData);
}
},
)
: SingleListTabPreview(
key: ValueKey(entity.tabId),
tabId: entity.tabId,
key: ValueKey(tabId),
tabId: tabId,
activeTabId: activeTab,
onClose: onClose,
sourceSearchQuery: switch (entity) {
DefaultTabEntity _ => null,
final SearchResultTabEntity entity => entity.searchQuery,
TabTreeEntity _ => throw UnimplementedError(
'TabTreeEntity not implemented in tab list view',
),
},
sourceSearchQuery: sourceSearchQuery,
groupToggle: groupToggle,
depth: depth,
);
}, [entity.tabId, activeTab, suggestedContainerId]);
}, [tabId, activeTab, suggestedContainerId, groupToggle, depth]);
return switch (dragData) {
ContainerDropData() => Opacity(
@@ -208,16 +240,69 @@ class _TabListView extends HookConsumerWidget {
final containerId = ref.watch(selectedContainerProvider);
final canManualReorder = ref.watch(canManualTabReorderProvider);
final reorderEnabled = tabsReorderable && canManualReorder;
final filteredTabEntities = ref.watch(
seamlessFilteredTabEntitiesProvider(
searchPartition: TabSearchPartition.preview,
// ignore: document_ignores using fast equatable
// ignore: provider_parameters
containerFilter: ContainerFilterById(containerId: containerId),
groupTrees: false,
),
final filterOptions = ref.watch(tabViewFilterControllerProvider);
final showHierarchicalTabs = filterOptions.showHierarchicalTabs;
final tabListDirection = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.tabListDirection),
);
final collapsedGroups = ref.watch(collapsedGroupsProvider);
final List<TabsWithRootAndDepthResult> treeRows = showHierarchicalTabs
? ref.watch(
watchTabsWithRootAndDepthProvider(
containerId,
).select((value) => value.value ?? const []),
)
: const <TabsWithRootAndDepthResult>[];
final hasActiveSearch = ref.watch(
tabSearchRepositoryProvider(
TabSearchPartition.preview,
).select((value) => (value.value?.query ?? '').isNotEmpty),
);
final List<TabViewItem> primaryRows;
if (hasActiveSearch || !showHierarchicalTabs) {
final flat = ref.watch(
seamlessFilteredTabEntitiesProvider(
searchPartition: TabSearchPartition.preview,
// ignore: document_ignores using fast equatable
// ignore: provider_parameters
containerFilter: ContainerFilterById(containerId: containerId),
groupTrees: false,
),
);
primaryRows = [
for (final entity in flat.value)
TabViewItem.search(
tabId: entity.tabId,
sourceSearchQuery: switch (entity) {
DefaultTabEntity _ => null,
final SearchResultTabEntity e => e.searchQuery,
TabTreeEntity _ => null,
},
),
];
} else {
final grouped = ref.watch(
groupedTabListItemsProvider(containerId: containerId),
);
primaryRows = [
for (final item in grouped.value)
switch (item) {
TabListStandaloneItem(:final tabId) => TabViewItem.standalone(
tabId: tabId,
),
final TabListParentGroup g => TabViewItem.parent(
tabId: g.tabId,
parentGroup: g,
),
final TabListChildItem c => TabViewItem.child(
tabId: c.tabId,
childItem: c,
),
},
];
}
final tabSuggestionsEnabled = ref.watch(
persistedBoolProvider(PersistedBoolKey.tabSuggestions),
@@ -228,12 +313,10 @@ class _TabListView extends HookConsumerWidget {
: EquatableValue(<TabEntity>[]);
final itemCount =
filteredTabEntities.value.length +
primaryRows.length +
//Limit to 3 sugegstions for now
math.min<int>(suggestedTabEntities.value.length, 3);
final displayItemCount = reorderEnabled
? filteredTabEntities.value.length
: itemCount;
final displayItemCount = reorderEnabled ? primaryRows.length : itemCount;
final activeTab = ref.watch(selectedTabProvider);
@@ -244,9 +327,7 @@ class _TabListView extends HookConsumerWidget {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients && activeTab != null) {
final index = filteredTabEntities.value.indexWhere(
(entity) => entity.tabId == activeTab,
);
final index = primaryRows.indexWhere((row) => row.tabId == activeTab);
if (index > -1) {
final viewportStart = scrollController.offset;
@@ -302,50 +383,50 @@ class _TabListView extends HookConsumerWidget {
itemCount: displayItemCount,
itemExtent: _itemHeight,
itemBuilder: (context, index) {
final TabEntity entity;
final String? suggestedId;
if (index < primaryRows.length) {
final row = primaryRows[index];
final tab = CustomDraggable(
key: Key(row.tabId),
data: TabDragData(row.tabId),
child: _TabDraggable(
tabId: row.tabId,
onClose: onClose,
sourceSearchQuery: row.sourceSearchQuery,
height: _itemHeight,
groupToggle: _listGroupToggleFor(row),
depth: _depthFor(row),
),
);
if (index < filteredTabEntities.value.length) {
entity = filteredTabEntities.value[index];
suggestedId = null;
} else {
final suggestedIndex =
index - filteredTabEntities.value.length;
entity = suggestedTabEntities.value[suggestedIndex];
suggestedId = containerId;
return TabDropTarget(
targetTabId: row.tabId,
child: TabContextMenuDraggable(
tabId: row.tabId,
data: tab.data! as TabDragData,
feedbackSize: Size(
MediaQuery.of(context).size.width,
_itemHeight,
),
child: tab.child,
),
);
}
final suggestedIndex = index - primaryRows.length;
final entity = suggestedTabEntities.value[suggestedIndex];
final tab = CustomDraggable(
key: Key(
suggestedId != null
? 'suggested_${entity.tabId}'
: entity.tabId,
),
data: suggestedId != null
? null
: TabDragData(entity.tabId),
key: Key('suggested_${entity.tabId}'),
child: _TabDraggable(
entity: entity,
tabId: entity.tabId,
onClose: onClose,
suggestedContainerId: suggestedId,
suggestedContainerId: containerId,
height: _itemHeight,
),
);
return TabDropTarget(
targetTabId: entity.tabId,
enabled: suggestedId == null,
child: tab.data is TabDragData
? TabContextMenuDraggable(
tabId: (tab.data! as TabDragData).tabId,
data: tab.data! as TabDragData,
feedbackSize: Size(
MediaQuery.of(context).size.width,
_itemHeight,
),
child: tab.child,
)
: tab.child,
enabled: false,
child: tab.child,
);
},
)
@@ -357,84 +438,59 @@ class _TabListView extends HookConsumerWidget {
ref.read(willAcceptDropProvider.notifier).clear();
},
onReorder: (oldIndex, newIndex) async {
final containerRepository = ref.read(
containerRepositoryProvider.notifier,
);
//Suggestions are at the end and not reorderable, so skip
if (oldIndex >= filteredTabEntities.value.length) {
if (oldIndex >= primaryRows.length) {
return;
}
final tabId = filteredTabEntities.value[oldIndex].tabId;
final containerId = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabContainerId(tabId);
var targetIndex = newIndex;
if (targetIndex > oldIndex) {
targetIndex -= 1;
}
targetIndex = targetIndex.clamp(
0,
filteredTabEntities.value.length - 1,
final result = buildTabViewReorderResult(
visibleItems: primaryRows,
treeRows: treeRows,
collapsedGroups: collapsedGroups,
oldIndex: oldIndex,
newIndex: newIndex,
tabListDirection: tabListDirection,
hierarchical: showHierarchicalTabs && !hasActiveSearch,
);
final String key;
if (targetIndex <= 0) {
key = await containerRepository.getLeadingOrderKey(
containerId,
);
} else if (targetIndex >=
filteredTabEntities.value.length - 1) {
key = await containerRepository.getTrailingOrderKey(
containerId,
);
} else {
if (targetIndex < oldIndex) {
key = (await containerRepository.getOrderKeyAfterTab(
filteredTabEntities.value[targetIndex - 1].tabId,
containerId,
))!;
} else {
key = await containerRepository.getOrderKeyBeforeTab(
filteredTabEntities.value[targetIndex + 1].tabId,
containerId,
);
}
}
if (result == null) return;
await ref
.read(tabDataRepositoryProvider.notifier)
.assignOrderKey(tabId, key);
.reorderTabs(
movingTabIds: result.movingTabIds,
previousTabId: result.previousTabId,
nextTabId: result.nextTabId,
);
},
itemBuilder: (context, index) {
if (index < filteredTabEntities.value.length) {
final entity = filteredTabEntities.value[index];
if (index < primaryRows.length) {
final row = primaryRows[index];
return CustomDraggable(
key: Key(entity.tabId),
data: TabDragData(entity.tabId),
key: Key(row.tabId),
data: TabDragData(row.tabId),
child: TabContextMenuDraggable(
tabId: entity.tabId,
tabId: row.tabId,
feedbackSize: Size.zero,
externalDrag: true,
child: _TabDraggable(
entity: entity,
tabId: row.tabId,
onClose: onClose,
sourceSearchQuery: row.sourceSearchQuery,
height: _itemHeight,
groupToggle: _listGroupToggleFor(row),
depth: _depthFor(row),
),
),
);
} else {
final suggestedIndex =
index - filteredTabEntities.value.length;
final suggestedIndex = index - primaryRows.length;
final entity = suggestedTabEntities.value[suggestedIndex];
return CustomDraggable(
key: Key('suggested_${entity.tabId}'),
child: _TabDraggable(
entity: entity,
tabId: entity.tabId,
onClose: onClose,
suggestedContainerId: containerId,
height: _itemHeight,
@@ -28,6 +28,7 @@ import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/utils/tab_close_confirmation.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';
@@ -127,11 +128,22 @@ class GridTabPreview extends HookConsumerWidget {
final VoidCallback? onDoubleTap;
final VoidCallback? onDelete;
final void Function(String host)? onDeleteAll;
final VoidCallback? onCloseSubtree;
final bool showPinBadge;
final Widget? trailingChild;
/// Hierarchy widget rendered in the top-left corner alongside any
/// pin badge / [trailingChild]. Used by the tabs grid to inline the
/// expand/collapse toggle without overlay layers.
final Widget? groupToggle;
/// Tree depth (>= 1 for any child). Renders a stack of overlapping
/// subdirectory glyphs in the bottom-left of the thumbnail to signal
/// nesting level at a glance.
final int depth;
const GridTabPreview({
required this.tabId,
required this.isActive,
@@ -139,8 +151,11 @@ class GridTabPreview extends HookConsumerWidget {
this.onDoubleTap,
this.onDelete,
this.onDeleteAll,
this.onCloseSubtree,
this.showPinBadge = false,
this.trailingChild,
this.groupToggle,
this.depth = 0,
super.key,
});
@@ -220,7 +235,9 @@ class GridTabPreview extends HookConsumerWidget {
else
Center(child: TabIcon(tabState: tabState, iconSize: 48)),
// Close button overlay
if (onDelete != null || onDeleteAll != null)
if (onDelete != null ||
onDeleteAll != null ||
onCloseSubtree != null)
Positioned(
top: 6.0,
right: 6.0,
@@ -237,6 +254,12 @@ class GridTabPreview extends HookConsumerWidget {
leadingIcon: const Icon(Icons.language),
child: const Text('Close from Same Host'),
),
if (onCloseSubtree != null)
MenuItemButton(
onPressed: onCloseSubtree,
leadingIcon: const Icon(Icons.account_tree),
child: const Text('Close Tab and Descendants'),
),
],
child: SizedBox(
width: 28,
@@ -252,7 +275,8 @@ class GridTabPreview extends HookConsumerWidget {
Radius.circular(8.0),
),
onTap: onDelete,
onLongPress: onDeleteAll != null
onLongPress:
onDeleteAll != null || onCloseSubtree != null
? () {
if (extendedDeleteMenuController.isOpen) {
extendedDeleteMenuController.close();
@@ -271,13 +295,24 @@ class GridTabPreview extends HookConsumerWidget {
),
),
),
if (trailingChild != null || isPinned)
if (depth > 0)
Positioned(
bottom: 6.0,
left: 6.0,
child: _GridDepthIndicator(depth: depth),
),
if (trailingChild != null || isPinned || groupToggle != null)
Positioned(
top: 6.0,
left: 6.0,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (groupToggle != null) ...[
groupToggle!,
if (trailingChild != null || isPinned)
const SizedBox(width: 4),
],
if (trailingChild != null) trailingChild!,
if (isPinned)
Padding(
@@ -400,19 +435,31 @@ class ListTabPreview extends HookConsumerWidget {
final VoidCallback? onTap;
final VoidCallback? onDelete;
final void Function(String host)? onDeleteAll;
final VoidCallback? onCloseSubtree;
final bool showPinBadge;
final Widget? trailingChild;
/// Hierarchy widget rendered inside the trailing row, just before the
/// close button. Used to inline the group expand/collapse toggle.
final Widget? groupToggle;
/// Tree depth of this row. Used to render an integrated indent guide
/// (vertical bar + L-stub) on the leading edge of the tile.
final int depth;
const ListTabPreview({
required this.tabId,
required this.isActive,
this.onTap,
this.onDelete,
this.onDeleteAll,
this.onCloseSubtree,
this.showPinBadge = false,
this.trailingChild,
this.groupToggle,
this.depth = 0,
super.key,
});
@@ -488,7 +535,7 @@ class ListTabPreview extends HookConsumerWidget {
const borderRadius = BorderRadius.all(Radius.circular(12.0));
return Container(
final card = Container(
margin: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 4.0),
decoration: BoxDecoration(
color: listBgColor,
@@ -557,7 +604,10 @@ class ListTabPreview extends HookConsumerWidget {
],
),
),
if (onDelete != null || onDeleteAll != null)
if (groupToggle != null) groupToggle!,
if (onDelete != null ||
onDeleteAll != null ||
onCloseSubtree != null)
MenuAnchor(
controller: extendedDeleteMenuController,
builder: (context, controller, child) {
@@ -571,10 +621,16 @@ class ListTabPreview extends HookConsumerWidget {
leadingIcon: const Icon(Icons.language),
child: const Text('Close from Same Host'),
),
if (onCloseSubtree != null)
MenuItemButton(
onPressed: onCloseSubtree,
leadingIcon: const Icon(Icons.account_tree),
child: const Text('Close Tab and Descendants'),
),
],
child: IconButton(
onPressed: onDelete,
onLongPress: onDeleteAll != null
onLongPress: onDeleteAll != null || onCloseSubtree != null
? () {
if (extendedDeleteMenuController.isOpen) {
extendedDeleteMenuController.close();
@@ -597,9 +653,102 @@ class ListTabPreview extends HookConsumerWidget {
),
),
);
if (depth <= 0) {
return card;
}
final levels = math.min(depth, _listMaxIndentLevels);
final indentWidth = levels * _listIndentStep;
final guideColor = colorScheme.outlineVariant.withAlpha(150);
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: indentWidth,
child: CustomPaint(
painter: _IndentGuidePainter(
color: guideColor,
stubInsetFromRight: 8.0,
),
),
),
Expanded(child: card),
],
);
}
}
const double _listIndentStep = 16.0;
const int _listMaxIndentLevels = 3;
// Cap glyph count so the badge stays readable on deep trees.
const int _gridMaxDepthGlyphs = 4;
class _GridDepthIndicator extends StatelessWidget {
final int depth;
const _GridDepthIndicator({required this.depth});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final glyphCount = math.min(depth, _gridMaxDepthGlyphs);
// Same shell as the top-left toggle / top-right close button:
// 28px tall, surfaceContainerHighest with 200 alpha, 8px radius,
// onSurfaceVariant icons at 16px.
return SizedBox(
height: 28,
child: Material(
color: scheme.surfaceContainerHighest.withAlpha(200),
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < glyphCount; i++)
Icon(
MdiIcons.subdirectoryArrowRight,
size: 16,
color: scheme.onSurfaceVariant,
),
],
),
),
),
);
}
}
class _IndentGuidePainter extends CustomPainter {
final Color color;
final double stubInsetFromRight;
_IndentGuidePainter({required this.color, required this.stubInsetFromRight});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = 2.0
..strokeCap = StrokeCap.round;
final guideX = size.width - stubInsetFromRight;
// L-stub is centred vertically in the row so it tracks the tile's
// mid-line regardless of itemExtent changes.
final stubY = size.height / 2;
canvas.drawLine(Offset(guideX, 0), Offset(guideX, size.height), paint);
canvas.drawLine(Offset(guideX, stubY), Offset(size.width, stubY), paint);
}
@override
bool shouldRepaint(covariant _IndentGuidePainter old) =>
old.color != color || old.stubInsetFromRight != stubInsetFromRight;
}
class SyncedListTabPreview extends StatelessWidget {
const SyncedListTabPreview({
super.key,
@@ -654,6 +803,9 @@ class SingleGridTabPreview extends HookConsumerWidget {
final void Function() onClose;
final void Function()? onBeforeDelete;
final Widget? groupToggle;
final int depth;
const SingleGridTabPreview({
required this.tabId,
required this.activeTabId,
@@ -661,6 +813,8 @@ class SingleGridTabPreview extends HookConsumerWidget {
required this.sourceSearchQuery,
this.deleteThreshold = 100,
this.onBeforeDelete,
this.groupToggle,
this.depth = 0,
super.key,
});
@@ -707,6 +861,8 @@ class SingleGridTabPreview extends HookConsumerWidget {
tabId: tabId,
isActive: tabId == activeTabId,
showPinBadge: true,
groupToggle: groupToggle,
depth: depth,
onTap: () async {
if (tabId != activeTabId) {
//Close first to avoid rebuilds
@@ -740,6 +896,27 @@ class SingleGridTabPreview extends HookConsumerWidget {
);
}
},
onCloseSubtree: () async {
final subtreeIds = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabDescendants(tabId)
.then((descendants) => descendants.keys.toList());
if (!context.mounted) return;
final didClose = await closeTabsWithConfirmation(
context,
ref,
subtreeIds,
);
if (context.mounted && didClose) {
ui_helper.showTabUndoClose(
context,
ref.read(tabRepositoryProvider.notifier).undoClose,
count: subtreeIds.length,
);
}
},
onDelete: () async {
onBeforeDelete?.call();
@@ -771,6 +948,9 @@ class SingleListTabPreview extends HookConsumerWidget {
final void Function() onClose;
final void Function()? onBeforeDelete;
final Widget? groupToggle;
final int depth;
const SingleListTabPreview({
required this.tabId,
required this.activeTabId,
@@ -778,6 +958,8 @@ class SingleListTabPreview extends HookConsumerWidget {
required this.sourceSearchQuery,
this.deleteThreshold = 100,
this.onBeforeDelete,
this.groupToggle,
this.depth = 0,
super.key,
});
@@ -824,6 +1006,8 @@ class SingleListTabPreview extends HookConsumerWidget {
tabId: tabId,
isActive: tabId == activeTabId,
showPinBadge: true,
groupToggle: groupToggle,
depth: depth,
onTap: () async {
if (tabId != activeTabId) {
//Close first to avoid rebuilds
@@ -857,6 +1041,27 @@ class SingleListTabPreview extends HookConsumerWidget {
);
}
},
onCloseSubtree: () async {
final subtreeIds = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabDescendants(tabId)
.then((descendants) => descendants.keys.toList());
if (!context.mounted) return;
final didClose = await closeTabsWithConfirmation(
context,
ref,
subtreeIds,
);
if (context.mounted && didClose) {
ui_helper.showTabUndoClose(
context,
ref.read(tabRepositoryProvider.notifier).undoClose,
count: subtreeIds.length,
);
}
},
onDelete: () async {
onBeforeDelete?.call();
@@ -427,6 +427,24 @@ class TabViewHeader extends HookConsumerWidget {
],
child: const Text('Sort'),
),
MenuItemButton(
leadingIcon: Icon(
filterOptions.showHierarchicalTabs
? Icons.check_box
: Icons.check_box_outline_blank,
),
onPressed: () {
ref
.read(
tabViewFilterControllerProvider
.notifier,
)
.setShowHierarchicalTabs(
!filterOptions.showHierarchicalTabs,
);
},
child: const Text('Hierarchical View'),
),
const Divider(),
// Date range picker
MenuItemButton(
@@ -0,0 +1,117 @@
/*
* 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/features/geckoview/features/tabs/data/entities/tab_entity.dart';
/// Render-time descriptor for one item in the local tabs list/grid.
///
/// Unifies the search/flat path and the grouped path so list and grid can
/// share reorder semantics.
sealed class TabViewItem {
String get tabId;
String? get sourceSearchQuery;
TabListParentGroup? get parentGroup;
TabListChildItem? get childItem;
const TabViewItem._();
const factory TabViewItem.search({
required String tabId,
required String? sourceSearchQuery,
}) = SearchTabViewItem;
const factory TabViewItem.standalone({required String tabId}) =
StandaloneTabViewItem;
const factory TabViewItem.parent({
required String tabId,
required TabListParentGroup parentGroup,
}) = ParentTabViewItem;
const factory TabViewItem.child({
required String tabId,
required TabListChildItem childItem,
}) = ChildTabViewItem;
}
class SearchTabViewItem extends TabViewItem {
@override
final String tabId;
@override
final String? sourceSearchQuery;
@override
TabListParentGroup? get parentGroup => null;
@override
TabListChildItem? get childItem => null;
const SearchTabViewItem({
required this.tabId,
required this.sourceSearchQuery,
}) : super._();
}
class StandaloneTabViewItem extends TabViewItem {
@override
final String tabId;
@override
String? get sourceSearchQuery => null;
@override
TabListParentGroup? get parentGroup => null;
@override
TabListChildItem? get childItem => null;
const StandaloneTabViewItem({required this.tabId}) : super._();
}
class ParentTabViewItem extends TabViewItem {
@override
final String tabId;
@override
final TabListParentGroup parentGroup;
@override
String? get sourceSearchQuery => null;
@override
TabListChildItem? get childItem => null;
const ParentTabViewItem({required this.tabId, required this.parentGroup})
: super._();
}
class ChildTabViewItem extends TabViewItem {
@override
final String tabId;
@override
final TabListChildItem childItem;
@override
String? get sourceSearchQuery => null;
@override
TabListParentGroup? get parentGroup => null;
const ChildTabViewItem({required this.tabId, required this.childItem})
: super._();
}
@@ -45,7 +45,7 @@ final class StartupPreferenceEnforcementServiceProvider
}
String _$startupPreferenceEnforcementServiceHash() =>
r'5cfe5aec33a9e9e077704ff519c10b0398c5085d';
r'074e09b7f3abd430946dce87ef1c529597e97866';
abstract class _$StartupPreferenceEnforcementService extends $Notifier<void> {
void build();
@@ -151,6 +151,16 @@ class ContainerDao extends DatabaseAccessor<TabDatabase>
);
}
SingleOrNullSelectable<String> getLastChildTabId(
String? containerId,
String parentId,
) {
return db.definitionsDrift.lastChildTabId(
containerId: containerId,
parentId: parentId,
);
}
SingleOrNullSelectable<String> generateOrderKeyAfterTabId(
String? containerId,
String tabId,
@@ -19,6 +19,7 @@
*/
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:lexo_rank/lexo_rank.dart';
import 'package:nullability/nullability.dart';
@@ -30,7 +31,6 @@ import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode
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';
import 'package:weblibre/features/user/data/models/general_settings.dart';
class SyncTabsResult {
final Set<String> deletedIsolationContextIds;
@@ -154,35 +154,54 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
Future<String> _generateOrderKey({
required Value<String?> parentId,
required Value<String?> containerId,
required NewTabPosition newTabPosition,
Value<String?> afterTabId = const Value.absent(),
}) async {
if (parentId.value.isNotEmpty) {
return await db.containerDao
.generateOrderKeyAfterTabId(containerId.value, parentId.value!)
.getSingleOrNull() ??
await db.containerDao
.generateLeadingOrderKey(containerId.value)
.getSingle();
} else {
return switch (newTabPosition) {
NewTabPosition.first =>
db.containerDao
.generateLeadingOrderKey(containerId.value)
.getSingle(),
NewTabPosition.end =>
db.containerDao
.generateTrailingOrderKey(containerId.value)
.getSingle(),
};
// Explicit "place after this tab" wins regardless of parent.
if (afterTabId.present && afterTabId.value != null) {
final key = await db.containerDao
.generateOrderKeyAfterTabId(containerId.value, afterTabId.value!)
.getSingleOrNull();
if (key != null) {
return key;
}
}
if (parentId.value.isNotEmpty) {
// Place new child after the last existing sibling, falling back to
// immediately after the parent if there are none yet.
final lastChildId = await db.containerDao
.getLastChildTabId(containerId.value, parentId.value!)
.getSingleOrNull();
final anchorTabId = lastChildId ?? parentId.value!;
final key = await db.containerDao
.generateOrderKeyAfterTabId(containerId.value, anchorTabId)
.getSingleOrNull();
if (key != null) {
return key;
}
// Defensive fallback: covers both "parent row not present" *and* the
// cross-container case where the parent lives in a different container
// than `containerId.value` (then `getLastChildTabId` and
// `generateOrderKeyAfterTabId` both yield null because they filter on
// the new container). In either case append to the end.
}
// Root tabs (or unresolved parent) always append to the end of the list.
// Display direction is applied at render time via TabListDirection /
// TabBarDirection settings, so we never need to insert at the front.
return db.containerDao
.generateTrailingOrderKey(containerId.value)
.getSingle();
}
Future<String> upsertTabTransactional(
Future<String> Function() createTab, {
required Value<String?> parentId,
NewTabPosition newTabPosition = NewTabPosition.first,
Value<String?> containerId = const Value.absent(),
Value<String?> orderKey = const Value.absent(),
Value<String?> afterTabId = const Value.absent(),
Value<Uri?> url = const Value.absent(),
Value<String?> title = const Value.absent(),
Value<TabMode> tabMode = const Value.absent(),
@@ -194,7 +213,7 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
await _generateOrderKey(
parentId: parentId,
containerId: containerId,
newTabPosition: newTabPosition,
afterTabId: afterTabId,
);
final Value<TabModeDbValue> persistedTabMode = tabMode.present
? Value(tabMode.value.toDbValue())
@@ -239,9 +258,9 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
String tabId, {
required TabSource source,
required Value<String?> parentId,
NewTabPosition newTabPosition = NewTabPosition.first,
Value<String?> containerId = const Value.absent(),
Value<String?> orderKey = const Value.absent(),
Value<String?> afterTabId = const Value.absent(),
Value<Uri?> url = const Value.absent(),
Value<String?> title = const Value.absent(),
Value<TabMode> tabMode = const Value.absent(),
@@ -252,7 +271,7 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
await _generateOrderKey(
parentId: parentId,
containerId: containerId,
newTabPosition: newTabPosition,
afterTabId: afterTabId,
);
final Value<TabModeDbValue> persistedTabMode = tabMode.present
? Value(tabMode.value.toDbValue())
@@ -298,9 +317,265 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
return statement.write(TabCompanion(containerId: Value(containerId)));
}
Future<void> assignOrderKey(String id, {required String orderKey}) {
final statement = _updateByIdStatement(id);
return statement.write(TabCompanion(orderKey: Value(orderKey)));
Future<void> reorderTabs({
required List<String> movingTabIds,
required String? previousTabId,
required String? nextTabId,
}) {
if (movingTabIds.isEmpty) {
return Future.value();
}
return db.transaction(() async {
final anchorIds = [
if (previousTabId != null) previousTabId,
if (nextTabId != null) nextTabId,
];
final anchors = anchorIds.isEmpty
? const <String, TabData>{}
: {
for (final tab in await (select(
db.tab,
)..where((t) => t.id.isIn(anchorIds))).get())
tab.id: tab,
};
final previousTab = previousTabId == null ? null : anchors[previousTabId];
final nextTab = nextTabId == null ? null : anchors[nextTabId];
final previousRank = previousTab == null
? null
: LexoRank.parse(previousTab.orderKey);
final nextRank = nextTab == null
? null
: LexoRank.parse(nextTab.orderKey);
final orderKeys = _generateOrderKeysBetween(
count: movingTabIds.length,
previousRank: previousRank,
nextRank: nextRank,
);
await batch((batch) {
for (var i = 0; i < movingTabIds.length; i++) {
batch.update(
db.tab,
TabCompanion(orderKey: Value(orderKeys[i])),
where: (t) => t.id.equals(movingTabIds[i]),
);
}
});
});
}
List<String> _generateOrderKeysBetween({
required int count,
required LexoRank? previousRank,
required LexoRank? nextRank,
}) {
if (count <= 0) {
return const [];
}
if (previousRank == null && nextRank == null) {
var rank = LexoRank.middle();
return [
for (var i = 0; i < count; i++)
() {
final value = rank.value;
rank = rank.genNext();
return value;
}(),
];
}
if (nextRank == null) {
var rank = previousRank!.genNext();
return [
for (var i = 0; i < count; i++)
() {
final value = rank.value;
rank = rank.genNext();
return value;
}(),
];
}
if (previousRank == null) {
var rank = nextRank;
final reversed = <String>[];
for (var i = 0; i < count; i++) {
rank = rank.genPrev();
reversed.add(rank.value);
}
return reversed.reversed.toList();
}
var upperBound = nextRank;
final reversed = <String>[];
for (var i = 0; i < count; i++) {
upperBound = previousRank.genBetween(upperBound);
reversed.add(upperBound.value);
}
return reversed.reversed.toList();
}
/// Reassigns `order_key` for grandchildren whose parent is being closed so
/// they slot into the closing scope at the position previously occupied by
/// their (closing) parent. Run *before* the close itself.
///
/// Edge cases worth knowing about:
///
/// - When the first batch of pending grandchildren has no surviving sibling
/// strictly before them, [previousRank] is `null` (rather than a tab in
/// the parent of the scope's parent). The assigned keys are correct
/// relative to siblings in this scope, but in a flat-by-order_key view
/// (e.g. the tab bar) the promoted children may now sort before unrelated
/// tabs from other scopes that originally sat before the closing tab in
/// storage. Hierarchical views mask this via `tabsWithRootAndDepth`.
///
/// - Within a scope, grandchildren are emitted in DFS order through the
/// closing chain (`childrenByParent[closingId]` recursively), not by raw
/// storage order. If a user manually reordered a grandchild to sit after
/// one of its uncles in storage and the uncle is also closing, the DFS
/// walk will re-emit them in tree order — silently overriding the manual
/// key. This is treated as "rebuild the scope on close".
///
/// - Only `order_key` is rewritten. `parent_id` is left untouched and is
/// normalised lazily by the `tab_maintain_parent_chain_on_delete` trigger
/// when the close itself runs.
Future<void> preservePromotedChildOrderOnClose(Iterable<String> tabIds) {
final closingIds = tabIds.toSet();
if (closingIds.isEmpty) {
return Future.value();
}
return db.transaction(() async {
final closingTabs = await (select(
db.tab,
)..where((t) => t.id.isIn(closingIds))).get();
if (closingTabs.isEmpty) {
return;
}
final directChildren =
await (select(db.tab)
..where((t) => t.parentId.isIn(closingIds))
..orderBy([(t) => OrderingTerm.asc(t.orderKey)]))
.get();
final closingTabById = {for (final tab in closingTabs) tab.id: tab};
final childrenByParent = <String, List<TabData>>{};
for (final child in directChildren) {
final parentId = child.parentId;
if (parentId == null) continue;
childrenByParent.putIfAbsent(parentId, () => []).add(child);
}
final promotedBoundaryCache = <String, List<TabData>>{};
List<TabData> promotedBoundaryChildren(String closingTabId) {
return promotedBoundaryCache.putIfAbsent(closingTabId, () {
final result = <TabData>[];
for (final child
in childrenByParent[closingTabId] ?? const <TabData>[]) {
if (closingIds.contains(child.id)) {
result.addAll(promotedBoundaryChildren(child.id));
} else {
result.add(child);
}
}
return result;
});
}
final representativeClosingTabs = closingTabs.where(
(tab) => !closingTabById.containsKey(tab.parentId),
);
final closingTabsByScope = groupBy(
representativeClosingTabs,
(TabData tab) => (containerId: tab.containerId, parentId: tab.parentId),
);
for (final entry in closingTabsByScope.entries) {
final scope = entry.key;
final scopedClosingTabs = entry.value.sorted(
(a, b) => a.orderKey.compareTo(b.orderKey),
);
final sameScopeTabs =
await (select(db.tab)
..where((t) {
final sameContainer = scope.containerId != null
? t.containerId.equals(scope.containerId!)
: t.containerId.isNull();
final sameParent = scope.parentId != null
? t.parentId.equals(scope.parentId!)
: t.parentId.isNull();
return sameContainer &
sameParent &
t.id.isNotIn(closingIds);
})
..orderBy([(t) => OrderingTerm.asc(t.orderKey)]))
.get();
var closingIndex = 0;
var survivorIndex = 0;
LexoRank? previousRank;
final pendingChildren = <TabData>[];
Future<void> assignPendingChildren(LexoRank? nextRank) async {
if (pendingChildren.isEmpty) {
return;
}
final orderKeys = _generateOrderKeysBetween(
count: pendingChildren.length,
previousRank: previousRank,
nextRank: nextRank,
);
for (var i = 0; i < pendingChildren.length; i++) {
await _updateByIdStatement(
pendingChildren[i].id,
).write(TabCompanion(orderKey: Value(orderKeys[i])));
}
pendingChildren.clear();
}
while (closingIndex < scopedClosingTabs.length ||
survivorIndex < sameScopeTabs.length) {
final nextClosing = closingIndex < scopedClosingTabs.length
? scopedClosingTabs[closingIndex]
: null;
final nextSurvivor = survivorIndex < sameScopeTabs.length
? sameScopeTabs[survivorIndex]
: null;
final takeClosing =
nextClosing != null &&
(nextSurvivor == null ||
nextClosing.orderKey.compareTo(nextSurvivor.orderKey) < 0);
if (takeClosing) {
pendingChildren.addAll(promotedBoundaryChildren(nextClosing.id));
closingIndex++;
continue;
}
final survivor = nextSurvivor!;
final survivorRank = LexoRank.parse(survivor.orderKey);
await assignPendingChildren(survivorRank);
previousRank = survivorRank;
survivorIndex++;
}
await assignPendingChildren(null);
}
});
}
Future<void> touchTab(String id, {required DateTime timestamp}) {
@@ -525,6 +800,16 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
);
}
SingleOrNullSelectable<String> lastSubtreeTabIdByOrderKey(
String tabId, {
required String? containerId,
}) {
return db.definitionsDrift.lastSubtreeTabIdByOrderKey(
tabId: tabId,
containerId: containerId,
);
}
Future<List<String>> getUnassignedRegularTabsOlderThan(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 = 7;
final int schemaVersion = 8;
@override
final int ftsTokenLimit = 10;
@@ -118,5 +118,24 @@ class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin {
from6To7: (m, schema) async {
await m.addColumn(schema.tab, schema.tab.isPinned);
},
from7To8: (m, schema) async {
// Composite index supporting `tabsWithRootAndDepth` (parent existence
// checks scoped per container) and `lastChildTabId` (last child of a
// parent within a container). On large containers SQLite was falling
// back to per-row scans on the recursive seed.
//
// Also drop any rows whose `parent_id` references a tab that no
// longer exists (e.g. left over from a close that ran without the
// delete trigger). `tab_maintain_parent_chain_on_delete` keeps this
// clean going forward; the one-shot UPDATE here removes legacy
// dangling pointers so the new index is built on consistent data.
await m.database.customStatement(
'UPDATE tab SET parent_id = NULL '
'WHERE parent_id IS NOT NULL '
'AND NOT EXISTS (SELECT 1 FROM tab p WHERE p.id = tab.parent_id)',
);
await m.createIndex(schema.idxTabParentContainer);
},
);
}
@@ -32,6 +32,7 @@ abstract class $TabDatabase extends i0.GeneratedDatabase {
List<i0.DatabaseSchemaEntity> get allSchemaEntities => [
container,
tab,
i1.idxTabParentContainer,
tabFts,
i1.tabMaintainParentChainOnDelete,
i1.tabAfterInsert,
@@ -722,12 +722,100 @@ i1.GeneratedColumn<int> _column_19(String aliasedName) =>
$customConstraints: 'NOT NULL DEFAULT 0',
defaultValue: const i1.CustomExpression('0'),
);
final class Schema8 extends i0.VersionedSchema {
Schema8({required super.database}) : super(version: 8);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
container,
tab,
idxTabParentContainer,
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 Shape5 tab = Shape5(
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_19,
_column_10,
_column_11,
_column_12,
_column_13,
_column_14,
_column_15,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxTabParentContainer = i1.Index(
'idx_tab_parent_container',
'CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)',
);
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',
);
}
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,
required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -756,6 +844,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from6To7(migrator, schema);
return 7;
case 7:
final schema = Schema8(database: database);
final migrator = i1.Migrator(database, schema);
await from7To8(migrator, schema);
return 8;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -768,6 +861,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5,
required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6,
required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from2To3: from2To3,
@@ -775,5 +869,6 @@ i1.OnUpgrade stepByStep({
from4To5: from4To5,
from5To6: from5To6,
from6To7: from6To7,
from7To8: from7To8,
),
);
@@ -37,7 +37,14 @@ CREATE TABLE tab(
)
);
CREATE VIRTUAL TABLE tab_fts
-- Composite index used by `tabsWithRootAndDepth` (the seed checks
-- `parent_id IS NULL OR NOT EXISTS(... WHERE p.id = parent_id AND
-- p.container_id IS :container_id)`) and by `lastChildTabId`
-- (`WHERE parent_id = ? AND container_id IS ?`). Without it, both queries
-- degrade to per-row scans on large containers.
CREATE INDEX idx_tab_parent_container ON tab(parent_id, container_id);
CREATE VIRTUAL TABLE tab_fts
USING fts5(
title,
url,
@@ -123,6 +130,11 @@ trailingOrderKey(REQUIRED :container_id AS TEXT OR NULL, :bucket AS INTEGER):
)
);
lastChildTabId(:parent_id AS TEXT, REQUIRED :container_id AS TEXT OR NULL):
SELECT id FROM tab
WHERE parent_id = :parent_id AND container_id IS :container_id
ORDER BY order_key DESC LIMIT 1;
orderKeyAfterTab(:tab_id AS TEXT, REQUIRED :container_id AS TEXT OR NULL):
WITH ordered_table AS (
SELECT id,
@@ -249,6 +261,61 @@ ON
d.timestamp = rs.max_timestamp
ORDER BY d.timestamp DESC;
-- Returns each tab in a container together with its (recursive) root id and
-- depth from that root. Drives the grouped-list rendering path. The provider
-- handles ordering, filter, and direction.
tabsWithRootAndDepth(REQUIRED :container_id AS TEXT OR NULL):
-- Seed the recursion from every tab in the target container whose parent is
-- not also in this container (parent NULL, parent missing, or parent moved
-- away). This treats a child whose ancestor lives elsewhere as a *local
-- root* rather than dropping it from the grouped view entirely.
WITH RECURSIVE walk(id, parent_id, order_key, root_id, depth) AS (
SELECT t.id, t.parent_id, t.order_key, t.id AS root_id, 0 AS depth
FROM tab t
WHERE t.container_id IS :container_id
AND (
t.parent_id IS NULL
OR NOT EXISTS (
SELECT 1 FROM tab p
WHERE p.id = t.parent_id AND p.container_id IS :container_id
)
)
UNION ALL
SELECT t.id, t.parent_id, t.order_key, w.root_id, w.depth + 1
FROM tab t
INNER JOIN walk w ON t.parent_id = w.id
WHERE t.container_id IS :container_id
)
SELECT id, parent_id, order_key, root_id, depth FROM walk;
-- Returns the id of the tab whose `order_key` is the largest within the
-- (inclusive) subtree rooted at :tab_id. Used to anchor a sibling insert
-- *after* the entire subtree of an existing tab.
--
-- Contiguity assumption: this query is only correct when subtrees occupy a
-- contiguous range in `order_key` space. The reorder algorithm preserves
-- this by moving subtrees as atomic blocks, but legacy rows or a flat
-- (non-hierarchical) reorder could interleave order_keys across scopes.
-- In that pathological case the returned anchor is still the largest-keyed
-- descendant, but it may sit *between* unrelated tabs rather than after the
-- entire subtree.
lastSubtreeTabIdByOrderKey(
:tab_id AS TEXT,
REQUIRED :container_id AS TEXT OR NULL
):
WITH RECURSIVE subtree AS (
SELECT id, order_key FROM tab
WHERE id = :tab_id AND container_id IS :container_id
UNION ALL
SELECT t.id, t.order_key
FROM tab t
INNER JOIN subtree s ON t.parent_id = s.id
WHERE t.container_id IS :container_id
)
SELECT id FROM subtree ORDER BY order_key DESC LIMIT 1;
unorderedTabDescendants:
WITH RECURSIVE descendants AS (
SELECT id, parent_id
@@ -2060,6 +2060,11 @@ class TabCompanion extends i0.UpdateCompanion<i3.TabData> {
}
}
i0.Index get idxTabParentContainer => i0.Index(
'idx_tab_parent_container',
'CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)',
);
class TabFts extends i0.Table
with i0.TableInfo<TabFts, i3.TabFt>, i0.VirtualTableInfo<TabFts, i3.TabFt> {
@override
@@ -2406,6 +2411,20 @@ class DefinitionsDrift extends i9.ModularAccessor {
).map((i0.QueryRow row) => row.read<String>('_c0'));
}
i0.Selectable<String> lastChildTabId({
required String parentId,
required String? containerId,
}) {
return customSelect(
'SELECT id FROM tab WHERE parent_id = ?1 AND container_id IS ?2 ORDER BY order_key DESC LIMIT 1',
variables: [
i0.Variable<String>(parentId),
i0.Variable<String>(containerId),
],
readsFrom: {tab},
).map((i0.QueryRow row) => row.read<String>('id'));
}
i0.Selectable<String> orderKeyAfterTab({
required String? containerId,
required String tabId,
@@ -2502,6 +2521,35 @@ class DefinitionsDrift extends i9.ModularAccessor {
);
}
i0.Selectable<TabsWithRootAndDepthResult> tabsWithRootAndDepth({
required String? containerId,
}) {
return customSelect(
'WITH RECURSIVE walk (id, parent_id, order_key, root_id, depth) AS (SELECT t.id, t.parent_id, t.order_key, t.id AS root_id, 0 AS depth FROM tab AS t WHERE t.container_id IS ?1 AND(t.parent_id IS NULL OR NOT EXISTS (SELECT 1 FROM tab AS p WHERE p.id = t.parent_id AND p.container_id IS ?1))UNION ALL SELECT t.id, t.parent_id, t.order_key, w.root_id, w.depth + 1 FROM tab AS t INNER JOIN walk AS w ON t.parent_id = w.id WHERE t.container_id IS ?1) SELECT id, parent_id, order_key, root_id, depth FROM walk',
variables: [i0.Variable<String>(containerId)],
readsFrom: {tab},
).map(
(i0.QueryRow row) => TabsWithRootAndDepthResult(
id: row.read<String>('id'),
parentId: row.readNullable<String>('parent_id'),
orderKey: row.read<String>('order_key'),
rootId: row.read<String>('root_id'),
depth: row.read<int>('depth'),
),
);
}
i0.Selectable<String> lastSubtreeTabIdByOrderKey({
required String tabId,
required String? containerId,
}) {
return customSelect(
'WITH RECURSIVE subtree AS (SELECT id, order_key FROM tab WHERE id = ?1 AND container_id IS ?2 UNION ALL SELECT t.id, t.order_key FROM tab AS t INNER JOIN subtree AS s ON t.parent_id = s.id WHERE t.container_id IS ?2) SELECT id FROM subtree ORDER BY order_key DESC LIMIT 1',
variables: [i0.Variable<String>(tabId), i0.Variable<String>(containerId)],
readsFrom: {tab},
).map((i0.QueryRow row) => row.read<String>('id'));
}
i0.Selectable<UnorderedTabDescendantsResult> unorderedTabDescendants({
required String tabId,
}) {
@@ -2658,6 +2706,21 @@ class TabTreesResult {
});
}
class TabsWithRootAndDepthResult {
final String id;
final String? parentId;
final String orderKey;
final String rootId;
final int depth;
TabsWithRootAndDepthResult({
required this.id,
this.parentId,
required this.orderKey,
required this.rootId,
required this.depth,
});
}
class UnorderedTabDescendantsResult {
final String id;
final String? parentId;
@@ -66,6 +66,94 @@ class SearchResultTabEntity extends TabEntity {
];
}
/// Sealed type for items rendered in the grouped flat list/grid views.
///
/// Distinct from [TabEntity] which serves the original flat-only path. The
/// grouped variant carries enough information to render parent-with-children
/// blocks while keeping a single ordered top-level list.
sealed class TabListItemEntity with FastEquatable {
String get tabId;
String get orderKey;
String? get containerId;
}
class TabListStandaloneItem extends TabListItemEntity {
@override
final String tabId;
@override
final String orderKey;
@override
final String? containerId;
TabListStandaloneItem({
required this.tabId,
required this.orderKey,
required this.containerId,
});
@override
List<Object?> get hashParameters => [tabId, orderKey, containerId];
}
class TabListParentGroup extends TabListItemEntity {
@override
final String tabId;
@override
final String orderKey;
@override
final String? containerId;
final int childCount;
TabListParentGroup({
required this.tabId,
required this.orderKey,
required this.containerId,
required this.childCount,
});
@override
List<Object?> get hashParameters => [
tabId,
orderKey,
containerId,
childCount,
];
}
class TabListChildItem extends TabListItemEntity {
@override
final String tabId;
@override
final String orderKey;
@override
final String? containerId;
final String parentId;
final String rootId;
final int depth;
final int childCount;
TabListChildItem({
required this.tabId,
required this.orderKey,
required this.containerId,
required this.parentId,
required this.rootId,
required this.depth,
this.childCount = 0,
});
@override
List<Object?> get hashParameters => [
tabId,
orderKey,
containerId,
parentId,
rootId,
depth,
childCount,
];
}
class TabTreeEntity extends TabEntity {
@override
final String tabId;
@@ -97,6 +97,17 @@ Stream<List<TabTreesResult>> watchTabTrees(Ref ref) {
return db.definitionsDrift.tabTrees().watch();
}
@Riverpod()
Stream<List<TabsWithRootAndDepthResult>> watchTabsWithRootAndDepth(
Ref ref,
String? containerId,
) {
final db = ref.watch(tabDatabaseProvider);
return db.definitionsDrift
.tabsWithRootAndDepth(containerId: containerId)
.watch();
}
@Riverpod()
Stream<Map<String, String?>> watchTabDescendants(Ref ref, String tabId) {
final db = ref.watch(tabDatabaseProvider);
@@ -370,6 +370,89 @@ final class WatchTabTreesProvider
String _$watchTabTreesHash() => r'a2be591acb6818ea8675a12d90e6c4684a0448a8';
@ProviderFor(watchTabsWithRootAndDepth)
final watchTabsWithRootAndDepthProvider = WatchTabsWithRootAndDepthFamily._();
final class WatchTabsWithRootAndDepthProvider
extends
$FunctionalProvider<
AsyncValue<List<TabsWithRootAndDepthResult>>,
List<TabsWithRootAndDepthResult>,
Stream<List<TabsWithRootAndDepthResult>>
>
with
$FutureModifier<List<TabsWithRootAndDepthResult>>,
$StreamProvider<List<TabsWithRootAndDepthResult>> {
WatchTabsWithRootAndDepthProvider._({
required WatchTabsWithRootAndDepthFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'watchTabsWithRootAndDepthProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$watchTabsWithRootAndDepthHash();
@override
String toString() {
return r'watchTabsWithRootAndDepthProvider'
''
'($argument)';
}
@$internal
@override
$StreamProviderElement<List<TabsWithRootAndDepthResult>> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<List<TabsWithRootAndDepthResult>> create(Ref ref) {
final argument = this.argument as String?;
return watchTabsWithRootAndDepth(ref, argument);
}
@override
bool operator ==(Object other) {
return other is WatchTabsWithRootAndDepthProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$watchTabsWithRootAndDepthHash() =>
r'dd485f4f60924e90f1f338237401dc95ca7e123f';
final class WatchTabsWithRootAndDepthFamily extends $Family
with
$FunctionalFamilyOverride<
Stream<List<TabsWithRootAndDepthResult>>,
String?
> {
WatchTabsWithRootAndDepthFamily._()
: super(
retry: null,
name: r'watchTabsWithRootAndDepthProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
WatchTabsWithRootAndDepthProvider call(String? containerId) =>
WatchTabsWithRootAndDepthProvider._(argument: containerId, from: this);
@override
String toString() => r'watchTabsWithRootAndDepthProvider';
}
@ProviderFor(watchTabDescendants)
final watchTabDescendantsProvider = WatchTabDescendantsFamily._();
@@ -122,11 +122,19 @@ class TabDataRepository extends _$TabDataRepository {
.setPinned(tabId, pinned: pinned);
}
Future<void> assignOrderKey(String tabId, String orderKey) {
Future<void> reorderTabs({
required List<String> movingTabIds,
required String? previousTabId,
required String? nextTabId,
}) {
return ref
.read(tabDatabaseProvider)
.tabDao
.assignOrderKey(tabId, orderKey: orderKey);
.reorderTabs(
movingTabIds: movingTabIds,
previousTabId: previousTabId,
nextTabId: nextTabId,
);
}
Future<int> closeAllTabs({
@@ -41,7 +41,7 @@ final class TabDataRepositoryProvider
}
}
String _$tabDataRepositoryHash() => r'e6482b5408aa567f626d2f0a9455b2cc908ba84f';
String _$tabDataRepositoryHash() => r'e14144f832ce32e1ae349df8970d025036526bc0';
abstract class _$TabDataRepository extends $Notifier<void> {
void build();
@@ -69,7 +69,8 @@ class _TabsSection extends StatelessWidget {
SettingSection(name: 'Tabs'),
_NewTabDefaultSection(),
_SmallWebTabDefaultSection(),
_NewTabPositionSection(),
_TabListDirectionSection(),
_TabBarDirectionSection(),
_ShowContainerUiTile(),
_ShowIsolatedTabUiTile(),
_CreateChildTabsTile(),
@@ -340,13 +341,13 @@ class _ExternalLinkHandlingSection extends HookConsumerWidget {
}
}
class _NewTabPositionSection extends HookConsumerWidget {
const _NewTabPositionSection();
class _TabListDirectionSection extends HookConsumerWidget {
const _TabListDirectionSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final newTabPosition = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.newTabPosition),
final direction = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.tabListDirection),
);
return Padding(
@@ -356,8 +357,65 @@ class _NewTabPositionSection extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('New Tab Position'),
subtitle: Text('Choose where newly created tabs appear by default'),
title: Text('Tab List Direction'),
subtitle: Text(
'Choose whether the newest tab appears at the top or bottom of the tab list',
),
leading: Icon(MdiIcons.formatListBulleted),
contentPadding: EdgeInsets.zero,
),
Center(
child: SegmentedButton(
showSelectedIcon: false,
segments: const [
ButtonSegment(
value: TabListDirection.newestFirst,
label: Text('Newest first'),
icon: Icon(MdiIcons.arrowCollapseUp),
),
ButtonSegment(
value: TabListDirection.oldestFirst,
label: Text('Oldest first'),
icon: Icon(MdiIcons.arrowCollapseDown),
),
],
selected: {direction},
onSelectionChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.tabListDirection(value.first),
);
},
),
),
],
),
);
}
}
class _TabBarDirectionSection extends HookConsumerWidget {
const _TabBarDirectionSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final direction = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.tabBarDirection),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Tab Bar Direction'),
subtitle: Text(
'Choose whether the newest tab appears on the left or right of the quick switcher',
),
leading: Icon(MdiIcons.reorderHorizontal),
contentPadding: EdgeInsets.zero,
),
@@ -366,23 +424,23 @@ class _NewTabPositionSection extends HookConsumerWidget {
showSelectedIcon: false,
segments: const [
ButtonSegment(
value: NewTabPosition.first,
label: Text('First'),
value: TabBarDirection.newestFirst,
label: Text('Newest first'),
icon: Icon(MdiIcons.arrowCollapseLeft),
),
ButtonSegment(
value: NewTabPosition.end,
label: Text('End'),
value: TabBarDirection.oldestFirst,
label: Text('Oldest first'),
icon: Icon(MdiIcons.arrowCollapseRight),
),
],
selected: {newTabPosition},
selected: {direction},
onSelectionChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.newTabPosition(value.first),
currentSettings.copyWith.tabBarDirection(value.first),
);
},
),
@@ -106,8 +106,13 @@ class ContextualToolbarSettingsScreen extends HookConsumerWidget {
else
SliverReorderableList(
itemCount: visibleConfigs.length,
onReorder: (oldIndex, newIndex) =>
_onReorder(visibleConfigs, oldIndex, newIndex, repository),
onReorder: (oldIndex, newIndex) => _onReorder(
visibleConfigs,
oldIndex,
newIndex,
repository,
isVisible: true,
),
itemBuilder: (context, index) {
final config = visibleConfigs[index];
return _ToolbarButtonConfigTile(
@@ -145,8 +150,13 @@ class ContextualToolbarSettingsScreen extends HookConsumerWidget {
else
SliverReorderableList(
itemCount: hiddenConfigs.length,
onReorder: (oldIndex, newIndex) =>
_onReorder(hiddenConfigs, oldIndex, newIndex, repository),
onReorder: (oldIndex, newIndex) => _onReorder(
hiddenConfigs,
oldIndex,
newIndex,
repository,
isVisible: false,
),
itemBuilder: (context, index) {
final config = hiddenConfigs[index];
return _ToolbarButtonConfigTile(
@@ -180,8 +190,9 @@ class ContextualToolbarSettingsScreen extends HookConsumerWidget {
List<ToolbarButtonConfig> configs,
int oldIndex,
int newIndex,
ContextualToolbarConfigRepository repository,
) {
ContextualToolbarConfigRepository repository, {
required bool isVisible,
}) {
if (oldIndex == newIndex) return;
final reorder = _resolveToolbarReorder(configs, oldIndex, newIndex);
if (reorder.targetIndex == oldIndex) return;
@@ -192,6 +203,7 @@ class ContextualToolbarSettingsScreen extends HookConsumerWidget {
oldIndex,
reorder.targetIndex,
reorder.movedId,
isVisible: isVisible,
),
);
}
@@ -201,22 +213,33 @@ class ContextualToolbarSettingsScreen extends HookConsumerWidget {
List<ToolbarButtonConfig> configs,
int oldIndex,
int targetIndex,
String movedId,
) async {
String movedId, {
required bool isVisible,
}) async {
// [configs] still contains the moved item. [targetIndex] is the
// post-removal destination index, clamped to [0, configs.length - 1].
// Because of the clamp, `>= configs.length - 1` only fires when the user
// dropped past the very last surviving item; `configs[targetIndex + 1]`
// is otherwise always a valid neighbour in the original list (since the
// moved item sits at oldIndex < targetIndex + 1 in the else branch).
final String orderKey;
if (targetIndex <= 0) {
orderKey = await repository.generateLeadingOrderKey();
orderKey = await repository.generateLeadingOrderKey(isVisible: isVisible);
} else if (targetIndex >= configs.length - 1) {
orderKey = await repository.generateTrailingOrderKey();
orderKey = await repository.generateTrailingOrderKey(
isVisible: isVisible,
);
} else if (targetIndex < oldIndex) {
orderKey =
await repository.generateOrderKeyAfterButtonId(
configs[targetIndex - 1].buttonId,
isVisible: isVisible,
) ??
await repository.generateLeadingOrderKey();
await repository.generateLeadingOrderKey(isVisible: isVisible);
} else {
orderKey = await repository.generateOrderKeyBeforeButtonId(
configs[targetIndex + 1].buttonId,
isVisible: isVisible,
);
}
await repository.assignOrderKey(movedId, orderKey: orderKey);
@@ -44,9 +44,24 @@ class ToolbarButtonConfigDao extends DatabaseAccessor<UserDatabase>
.write(ToolbarButtonConfigsCompanion(orderKey: Value(orderKey)));
Future<void> assignVisibility(String buttonId, {required bool visible}) =>
(update(db.toolbarButtonConfigs)
..where((t) => t.buttonId.equals(buttonId)))
.write(ToolbarButtonConfigsCompanion(isVisible: Value(visible)));
transaction(() async {
// Land the toggled button at the trailing edge of its *new* section
// so its order_key always stays inside the visibility partition.
// Without this, a button keeps the key it was assigned in the other
// section and lands at an arbitrary position after the toggle.
final orderKey = await generateTrailingOrderKey(
isVisible: visible,
).getSingle();
await (update(
db.toolbarButtonConfigs,
)..where((t) => t.buttonId.equals(buttonId))).write(
ToolbarButtonConfigsCompanion(
isVisible: Value(visible),
orderKey: Value(orderKey),
),
);
});
Future<void> assignFallback(String buttonId, String? fallbackId) =>
(update(db.toolbarButtonConfigs)
@@ -74,7 +89,9 @@ class ToolbarButtonConfigDao extends DatabaseAccessor<UserDatabase>
await transaction(() async {
final inserted = <ToolbarButtonConfig>[];
for (final def in missing) {
final orderKey = await generateTrailingOrderKey().getSingle();
final orderKey = await generateTrailingOrderKey(
isVisible: def.defaultVisible,
).getSingle();
inserted.add(
ToolbarButtonConfig(
buttonId: def.buttonId,
@@ -96,18 +113,37 @@ class ToolbarButtonConfigDao extends DatabaseAccessor<UserDatabase>
});
}
SingleSelectable<String> generateLeadingOrderKey({int bucket = 0}) =>
db.definitionsDrift.toolbarLeadingOrderKey(bucket: bucket);
SingleSelectable<String> generateLeadingOrderKey({
int bucket = 0,
required bool isVisible,
}) => db.definitionsDrift.toolbarLeadingOrderKey(
bucket: bucket,
isVisible: isVisible,
);
SingleSelectable<String> generateTrailingOrderKey({int bucket = 0}) =>
db.definitionsDrift.toolbarTrailingOrderKey(bucket: bucket);
SingleSelectable<String> generateTrailingOrderKey({
int bucket = 0,
required bool isVisible,
}) => db.definitionsDrift.toolbarTrailingOrderKey(
bucket: bucket,
isVisible: isVisible,
);
SingleOrNullSelectable<String> generateOrderKeyAfterButtonId(
String buttonId,
) => db.definitionsDrift.toolbarOrderKeyAfterButton(buttonId: buttonId);
String buttonId, {
required bool isVisible,
}) => db.definitionsDrift.toolbarOrderKeyAfterButton(
buttonId: buttonId,
isVisible: isVisible,
);
SingleSelectable<String> generateOrderKeyBeforeButtonId(String buttonId) =>
db.definitionsDrift.toolbarOrderKeyBeforeButton(buttonId: buttonId);
SingleSelectable<String> generateOrderKeyBeforeButtonId(
String buttonId, {
required bool isVisible,
}) => db.definitionsDrift.toolbarOrderKeyBeforeButton(
buttonId: buttonId,
isVisible: isVisible,
);
Future<void> _insertWithDeferredFallbacks(
List<ToolbarButtonConfig> configs,
@@ -31,47 +31,56 @@ CREATE TABLE toolbar_button_configs (
CREATE INDEX idx_toolbar_order_key ON toolbar_button_configs(order_key);
toolbarLeadingOrderKey(:bucket AS INTEGER):
-- All four queries are scoped to a visibility partition (`is_visible`) so the
-- generated key falls strictly within the targeted section. The Customize
-- Toolbar UI renders Enabled and Disabled as two independent reorderable
-- lists; without the partition, leading/trailing keys would cross the section
-- boundary and surface as buttons "jumping" after a visibility toggle.
toolbarLeadingOrderKey(:bucket AS INTEGER, :is_visible AS BOOL):
SELECT lexo_rank_previous(
:bucket,
(
SELECT order_key
FROM toolbar_button_configs
WHERE is_visible = :is_visible
ORDER BY order_key
LIMIT 1
)
);
toolbarTrailingOrderKey(:bucket AS INTEGER):
toolbarTrailingOrderKey(:bucket AS INTEGER, :is_visible AS BOOL):
SELECT lexo_rank_next(
:bucket,
(
SELECT order_key
FROM toolbar_button_configs
WHERE is_visible = :is_visible
ORDER BY order_key DESC
LIMIT 1
)
);
toolbarOrderKeyAfterButton(:button_id AS TEXT):
toolbarOrderKeyAfterButton(:button_id AS TEXT, :is_visible AS BOOL):
WITH ordered_table AS (
SELECT
button_id,
order_key,
LEAD(order_key) OVER (ORDER BY order_key) AS next_order_key
FROM toolbar_button_configs
WHERE is_visible = :is_visible
)
SELECT lexo_rank_reorder_after(order_key, next_order_key)
FROM ordered_table
WHERE button_id = :button_id;
toolbarOrderKeyBeforeButton(:button_id AS TEXT):
toolbarOrderKeyBeforeButton(:button_id AS TEXT, :is_visible AS BOOL):
WITH ordered_table AS (
SELECT
button_id,
order_key,
LAG(order_key) OVER (ORDER BY order_key) AS prev_order_key
FROM toolbar_button_configs
WHERE is_visible = :is_visible
)
SELECT lexo_rank_reorder_before(order_key, prev_order_key)
FROM ordered_table
@@ -2024,36 +2024,46 @@ i0.Index get idxToolbarOrderKey => i0.Index(
class DefinitionsDrift extends i3.ModularAccessor {
DefinitionsDrift(i0.GeneratedDatabase db) : super(db);
i0.Selectable<String> toolbarLeadingOrderKey({required int bucket}) {
i0.Selectable<String> toolbarLeadingOrderKey({
required int bucket,
required bool isVisible,
}) {
return customSelect(
'SELECT lexo_rank_previous(?1, (SELECT order_key FROM toolbar_button_configs ORDER BY order_key LIMIT 1)) AS _c0',
variables: [i0.Variable<int>(bucket)],
'SELECT lexo_rank_previous(?1, (SELECT order_key FROM toolbar_button_configs WHERE is_visible = ?2 ORDER BY order_key LIMIT 1)) AS _c0',
variables: [i0.Variable<int>(bucket), i0.Variable<bool>(isVisible)],
readsFrom: {toolbarButtonConfigs},
).map((i0.QueryRow row) => row.read<String>('_c0'));
}
i0.Selectable<String> toolbarTrailingOrderKey({required int bucket}) {
i0.Selectable<String> toolbarTrailingOrderKey({
required int bucket,
required bool isVisible,
}) {
return customSelect(
'SELECT lexo_rank_next(?1, (SELECT order_key FROM toolbar_button_configs ORDER BY order_key DESC LIMIT 1)) AS _c0',
variables: [i0.Variable<int>(bucket)],
'SELECT lexo_rank_next(?1, (SELECT order_key FROM toolbar_button_configs WHERE is_visible = ?2 ORDER BY order_key DESC LIMIT 1)) AS _c0',
variables: [i0.Variable<int>(bucket), i0.Variable<bool>(isVisible)],
readsFrom: {toolbarButtonConfigs},
).map((i0.QueryRow row) => row.read<String>('_c0'));
}
i0.Selectable<String> toolbarOrderKeyAfterButton({required String buttonId}) {
i0.Selectable<String> toolbarOrderKeyAfterButton({
required bool isVisible,
required String buttonId,
}) {
return customSelect(
'WITH ordered_table AS (SELECT button_id, order_key, LEAD(order_key)OVER (ORDER BY order_key RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS) AS next_order_key FROM toolbar_button_configs) SELECT lexo_rank_reorder_after(order_key, next_order_key) AS _c0 FROM ordered_table WHERE button_id = ?1',
variables: [i0.Variable<String>(buttonId)],
'WITH ordered_table AS (SELECT button_id, order_key, LEAD(order_key)OVER (ORDER BY order_key RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS) AS next_order_key FROM toolbar_button_configs WHERE is_visible = ?1) SELECT lexo_rank_reorder_after(order_key, next_order_key) AS _c0 FROM ordered_table WHERE button_id = ?2',
variables: [i0.Variable<bool>(isVisible), i0.Variable<String>(buttonId)],
readsFrom: {toolbarButtonConfigs},
).map((i0.QueryRow row) => row.read<String>('_c0'));
}
i0.Selectable<String> toolbarOrderKeyBeforeButton({
required bool isVisible,
required String buttonId,
}) {
return customSelect(
'WITH ordered_table AS (SELECT button_id, order_key, LAG(order_key)OVER (ORDER BY order_key RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS) AS prev_order_key FROM toolbar_button_configs) SELECT lexo_rank_reorder_before(order_key, prev_order_key) AS _c0 FROM ordered_table WHERE button_id = ?1',
variables: [i0.Variable<String>(buttonId)],
'WITH ordered_table AS (SELECT button_id, order_key, LAG(order_key)OVER (ORDER BY order_key RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS) AS prev_order_key FROM toolbar_button_configs WHERE is_visible = ?1) SELECT lexo_rank_reorder_before(order_key, prev_order_key) AS _c0 FROM ordered_table WHERE button_id = ?2',
variables: [i0.Variable<bool>(isVisible), i0.Variable<String>(buttonId)],
readsFrom: {toolbarButtonConfigs},
).map((i0.QueryRow row) => row.read<String>('_c0'));
}
@@ -46,7 +46,9 @@ enum QuickTabSwitcherMode { lastUsedTabs, containerTabs }
enum TabIntentOpenSetting { regular, private, ask }
enum NewTabPosition { first, end }
enum TabListDirection { newestFirst, oldestFirst }
enum TabBarDirection { newestFirst, oldestFirst }
enum TabBarPosition { top, bottom }
@@ -86,7 +88,8 @@ class GeneralSettings with FastEquatable {
final bool showIsolatedTabUi;
@JsonKey(name: 'defaultCreateTabType')
final TabType storedDefaultCreateTabType;
final NewTabPosition newTabPosition;
final TabListDirection tabListDirection;
final TabBarDirection tabBarDirection;
final TabIntentOpenSetting tabIntentOpenSetting;
final bool autoHideTabBar;
final TabBarSwipeAction tabBarSwipeAction;
@@ -140,7 +143,8 @@ class GeneralSettings with FastEquatable {
required this.showContainerUi,
required this.showIsolatedTabUi,
required this.storedDefaultCreateTabType,
required this.newTabPosition,
required this.tabListDirection,
required this.tabBarDirection,
required this.tabIntentOpenSetting,
required this.autoHideTabBar,
required this.tabBarSwipeAction,
@@ -195,7 +199,8 @@ class GeneralSettings with FastEquatable {
bool? showContainerUi,
bool? showIsolatedTabUi,
TabType? storedDefaultCreateTabType,
NewTabPosition? newTabPosition,
TabListDirection? tabListDirection,
TabBarDirection? tabBarDirection,
TabIntentOpenSetting? tabIntentOpenSetting,
bool? autoHideTabBar,
TabBarSwipeAction? tabBarSwipeAction,
@@ -248,7 +253,8 @@ class GeneralSettings with FastEquatable {
showIsolatedTabUi = showIsolatedTabUi ?? true,
storedDefaultCreateTabType =
storedDefaultCreateTabType ?? TabType.regular,
newTabPosition = newTabPosition ?? NewTabPosition.first,
tabListDirection = tabListDirection ?? TabListDirection.newestFirst,
tabBarDirection = tabBarDirection ?? TabBarDirection.newestFirst,
tabIntentOpenSetting = tabIntentOpenSetting ?? TabIntentOpenSetting.ask,
autoHideTabBar = autoHideTabBar ?? true,
tabBarSwipeAction =
@@ -295,8 +301,23 @@ class GeneralSettings with FastEquatable {
blockExternalAppsEnabled = blockExternalAppsEnabled ?? false,
externalAppIntentPolicies = externalAppIntentPolicies ?? const {};
factory GeneralSettings.fromJson(Map<String, dynamic> json) =>
_$GeneralSettingsFromJson(json);
factory GeneralSettings.fromJson(Map<String, dynamic> json) {
// Migrate legacy `newTabPosition` setting to direction settings.
// Old `first` (new tabs at top) → newestFirst; `end` → oldestFirst.
// TODO: Drop this fallback (and the `newTabPosition` row in the user
// settings DB) once enough releases have shipped that rolling back to a
// version without `tabListDirection`/`tabBarDirection` is no longer a
// concern.
final legacyNewTabPosition = json['newTabPosition'];
if (legacyNewTabPosition != null) {
final mapped = legacyNewTabPosition == 'end'
? 'oldestFirst'
: 'newestFirst';
json.putIfAbsent('tabListDirection', () => mapped);
json.putIfAbsent('tabBarDirection', () => mapped);
}
return _$GeneralSettingsFromJson(json);
}
Map<String, dynamic> toJson() => _$GeneralSettingsToJson(this);
@@ -332,7 +353,8 @@ class GeneralSettings with FastEquatable {
showContainerUi,
showIsolatedTabUi,
storedDefaultCreateTabType,
newTabPosition,
tabListDirection,
tabBarDirection,
tabIntentOpenSetting,
autoHideTabBar,
tabBarSwipeAction,
@@ -43,7 +43,9 @@ abstract class _$GeneralSettingsCWProxy {
TabType storedDefaultCreateTabType,
);
GeneralSettings newTabPosition(NewTabPosition newTabPosition);
GeneralSettings tabListDirection(TabListDirection tabListDirection);
GeneralSettings tabBarDirection(TabBarDirection tabBarDirection);
GeneralSettings tabIntentOpenSetting(
TabIntentOpenSetting tabIntentOpenSetting,
@@ -154,7 +156,8 @@ abstract class _$GeneralSettingsCWProxy {
bool showContainerUi,
bool showIsolatedTabUi,
TabType storedDefaultCreateTabType,
NewTabPosition newTabPosition,
TabListDirection tabListDirection,
TabBarDirection tabBarDirection,
TabIntentOpenSetting tabIntentOpenSetting,
bool autoHideTabBar,
TabBarSwipeAction tabBarSwipeAction,
@@ -265,8 +268,12 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
) => call(storedDefaultCreateTabType: storedDefaultCreateTabType);
@override
GeneralSettings newTabPosition(NewTabPosition newTabPosition) =>
call(newTabPosition: newTabPosition);
GeneralSettings tabListDirection(TabListDirection tabListDirection) =>
call(tabListDirection: tabListDirection);
@override
GeneralSettings tabBarDirection(TabBarDirection tabBarDirection) =>
call(tabBarDirection: tabBarDirection);
@override
GeneralSettings tabIntentOpenSetting(
@@ -447,7 +454,8 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
Object? showContainerUi = const $CopyWithPlaceholder(),
Object? showIsolatedTabUi = const $CopyWithPlaceholder(),
Object? storedDefaultCreateTabType = const $CopyWithPlaceholder(),
Object? newTabPosition = const $CopyWithPlaceholder(),
Object? tabListDirection = const $CopyWithPlaceholder(),
Object? tabBarDirection = const $CopyWithPlaceholder(),
Object? tabIntentOpenSetting = const $CopyWithPlaceholder(),
Object? autoHideTabBar = const $CopyWithPlaceholder(),
Object? tabBarSwipeAction = const $CopyWithPlaceholder(),
@@ -572,12 +580,18 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
? _value.storedDefaultCreateTabType
// ignore: cast_nullable_to_non_nullable
: storedDefaultCreateTabType as TabType,
newTabPosition:
newTabPosition == const $CopyWithPlaceholder() ||
newTabPosition == null
? _value.newTabPosition
tabListDirection:
tabListDirection == const $CopyWithPlaceholder() ||
tabListDirection == null
? _value.tabListDirection
// ignore: cast_nullable_to_non_nullable
: newTabPosition as NewTabPosition,
: tabListDirection as TabListDirection,
tabBarDirection:
tabBarDirection == const $CopyWithPlaceholder() ||
tabBarDirection == null
? _value.tabBarDirection
// ignore: cast_nullable_to_non_nullable
: tabBarDirection as TabBarDirection,
tabIntentOpenSetting:
tabIntentOpenSetting == const $CopyWithPlaceholder() ||
tabIntentOpenSetting == null
@@ -836,9 +850,13 @@ GeneralSettings _$GeneralSettingsFromJson(
_$TabTypeEnumMap,
json['defaultCreateTabType'],
),
newTabPosition: $enumDecodeNullable(
_$NewTabPositionEnumMap,
json['newTabPosition'],
tabListDirection: $enumDecodeNullable(
_$TabListDirectionEnumMap,
json['tabListDirection'],
),
tabBarDirection: $enumDecodeNullable(
_$TabBarDirectionEnumMap,
json['tabBarDirection'],
),
tabIntentOpenSetting: $enumDecodeNullable(
_$TabIntentOpenSettingEnumMap,
@@ -937,7 +955,8 @@ Map<String, dynamic> _$GeneralSettingsToJson(
'showIsolatedTabUi': instance.showIsolatedTabUi,
'defaultCreateTabType':
_$TabTypeEnumMap[instance.storedDefaultCreateTabType]!,
'newTabPosition': _$NewTabPositionEnumMap[instance.newTabPosition]!,
'tabListDirection': _$TabListDirectionEnumMap[instance.tabListDirection]!,
'tabBarDirection': _$TabBarDirectionEnumMap[instance.tabBarDirection]!,
'tabIntentOpenSetting':
_$TabIntentOpenSettingEnumMap[instance.tabIntentOpenSetting]!,
'autoHideTabBar': instance.autoHideTabBar,
@@ -1012,9 +1031,14 @@ const _$TabTypeEnumMap = {
TabType.isolated: 'isolated',
};
const _$NewTabPositionEnumMap = {
NewTabPosition.first: 'first',
NewTabPosition.end: 'end',
const _$TabListDirectionEnumMap = {
TabListDirection.newestFirst: 'newestFirst',
TabListDirection.oldestFirst: 'oldestFirst',
};
const _$TabBarDirectionEnumMap = {
TabBarDirection.newestFirst: 'newestFirst',
TabBarDirection.oldestFirst: 'oldestFirst',
};
const _$TabIntentOpenSettingEnumMap = {
@@ -106,6 +106,14 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository {
DriftSqlType.string,
db.typeMapping,
),
'tabListDirection': settings['tabListDirection']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'tabBarDirection': settings['tabBarDirection']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'tabIntentOpenSetting': settings['tabIntentOpenSetting']?.readAs(
DriftSqlType.string,
db.typeMapping,
@@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider
}
String _$generalSettingsRepositoryHash() =>
r'f75d5cb1963c16404f5fd02db0097b72451717eb';
r'5fe717f8bccad163fa0cb8ec3294c4b00847e141';
abstract class _$GeneralSettingsRepository
extends $StreamNotifier<GeneralSettings> {