diff --git a/apps/weblibre/drift_schemas/tabs/drift_schema_v8.json b/apps/weblibre/drift_schemas/tabs/drift_schema_v8.json new file mode 100644 index 00000000..604b3f1d --- /dev/null +++ b/apps/weblibre/drift_schemas/tabs/drift_schema_v8.json @@ -0,0 +1,490 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.3.0" + }, + "options": { + "store_date_time_values_as_text": false + }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "container", + "was_declared_in_moor": true, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "color", + "getter_name": "color", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const ColorConverter()", + "dart_type_name": "Color" + } + }, + { + "name": "metadata", + "getter_name": "metadata", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const ContainerMetadataConverter()", + "dart_type_name": "ContainerMetadata" + } + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 1, + "references": [ + 0 + ], + "type": "table", + "data": { + "name": "tab", + "was_declared_in_moor": true, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "source", + "getter_name": "source", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(TabSource.values)", + "dart_type_name": "TabSource" + } + }, + { + "name": "parent_id", + "getter_name": "parentId", + "moor_type": "string", + "nullable": true, + "customConstraints": "REFERENCES tab(id)ON DELETE SET NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "container_id", + "getter_name": "containerId", + "moor_type": "string", + "nullable": true, + "customConstraints": "REFERENCES container(id)ON DELETE CASCADE", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "container", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "order_key", + "getter_name": "orderKey", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const UriConverterNullable()", + "dart_type_name": "Uri?" + } + }, + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "tab_mode", + "getter_name": "tabMode", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 0", + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(TabModeDbValue.values)", + "dart_type_name": "TabModeDbValue" + } + }, + { + "name": "isolation_context_id", + "getter_name": "isolationContextId", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_pinned", + "getter_name": "isPinned", + "moor_type": "bool", + "nullable": false, + "customConstraints": "NOT NULL DEFAULT 0", + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_probably_readerable", + "getter_name": "isProbablyReaderable", + "moor_type": "bool", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_markdown", + "getter_name": "extractedContentMarkdown", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_plain", + "getter_name": "extractedContentPlain", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_markdown", + "getter_name": "fullContentMarkdown", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_plain", + "getter_name": "fullContentPlain", + "moor_type": "string", + "nullable": true, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "timestamp", + "getter_name": "timestamp", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [ + "CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))" + ] + } + }, + { + "id": 2, + "references": [ + 1 + ], + "type": "index", + "data": { + "on": 1, + "name": "idx_tab_parent_container", + "sql": "CREATE INDEX idx_tab_parent_container ON tab(parent_id, container_id);", + "unique": false, + "columns": [] + } + }, + { + "id": 3, + "references": [ + 1 + ], + "type": "table", + "data": { + "name": "tab_fts", + "was_declared_in_moor": true, + "columns": [ + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "extracted_content_plain", + "getter_name": "extractedContentPlain", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "full_content_plain", + "getter_name": "fullContentPlain", + "moor_type": "string", + "nullable": false, + "customConstraints": "", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": true, + "create_virtual_stmt": "CREATE VIRTUAL TABLE \"tab_fts\" USING fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize=\"trigram\")", + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 4, + "references": [ + 1 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1 + ], + "name": "tab_maintain_parent_chain_on_delete", + "sql": "CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN\n -- Update all children of the deleted row to point to its parent (grandparent)\n -- Only if the grandparent exists, otherwise set to NULL\n UPDATE tab\n SET parent_id = CASE\n WHEN OLD.parent_id IS NOT NULL AND\n EXISTS(SELECT 1 FROM tab WHERE id = OLD.parent_id)\n THEN OLD.parent_id\n ELSE NULL\n END\n WHERE parent_id = OLD.id;\nEND;" + } + }, + { + "id": 5, + "references": [ + 1, + 3 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 3 + ], + "name": "tab_after_insert", + "sql": "CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN\n INSERT INTO \n tab_fts(rowid, title, url, extracted_content_plain, full_content_plain) \n VALUES (new.rowid, new.title, new.url, new.extracted_content_plain, new.full_content_plain);\nEND;" + } + }, + { + "id": 6, + "references": [ + 1, + 3 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 3 + ], + "name": "tab_after_delete", + "sql": "CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN\n INSERT INTO \n tab_fts(tab_fts, rowid, title, url, extracted_content_plain, full_content_plain) \n VALUES('delete', old.rowid, old.title, old.url, old.extracted_content_plain, old.full_content_plain);\nEND;" + } + }, + { + "id": 7, + "references": [ + 1, + 3 + ], + "type": "trigger", + "data": { + "on": 1, + "references_in_body": [ + 1, + 3 + ], + "name": "tab_after_update", + "sql": "CREATE TRIGGER tab_after_update AFTER UPDATE ON tab BEGIN\n INSERT INTO \n tab_fts(tab_fts, rowid, title, url, extracted_content_plain, full_content_plain) \n VALUES('delete', old.rowid, old.title, old.url, old.extracted_content_plain, old.full_content_plain);\n INSERT INTO \n tab_fts(rowid, title, url, extracted_content_plain, full_content_plain) \n VALUES (new.rowid, new.title, new.url, new.extracted_content_plain, new.full_content_plain);\nEND;" + } + } + ], + "fixed_sql": [ + { + "name": "container", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"container\" (\"id\" TEXT PRIMARY KEY NOT NULL, \"name\" TEXT, \"color\" INTEGER NOT NULL, \"metadata\" TEXT);" + } + ] + }, + { + "name": "tab", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"tab\" (\"id\" TEXT PRIMARY KEY NOT NULL, \"source\" INTEGER NOT NULL, \"parent_id\" TEXT REFERENCES tab(id)ON DELETE SET NULL, \"container_id\" TEXT REFERENCES container(id)ON DELETE CASCADE, \"order_key\" TEXT NOT NULL, \"url\" TEXT, \"title\" TEXT, \"tab_mode\" INTEGER NOT NULL DEFAULT 0, \"isolation_context_id\" TEXT, \"is_pinned\" INTEGER NOT NULL DEFAULT 0, \"is_probably_readerable\" INTEGER, \"extracted_content_markdown\" TEXT, \"extracted_content_plain\" TEXT, \"full_content_markdown\" TEXT, \"full_content_plain\" TEXT, \"timestamp\" INTEGER NOT NULL, CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL)));" + } + ] + }, + { + "name": "idx_tab_parent_container", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)" + } + ] + }, + { + "name": "tab_fts", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE VIRTUAL TABLE IF NOT EXISTS \"tab_fts\" USING fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize=\"trigram\");" + } + ] + }, + { + "name": "tab_maintain_parent_chain_on_delete", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END" + } + ] + }, + { + "name": "tab_after_insert", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts (\"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES (new.\"rowid\", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END" + } + ] + }, + { + "name": "tab_after_delete", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, \"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES ('delete', old.\"rowid\", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END" + } + ] + }, + { + "name": "tab_after_update", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TRIGGER tab_after_update AFTER UPDATE ON tab BEGIN INSERT INTO tab_fts (tab_fts, \"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES ('delete', old.\"rowid\", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts (\"rowid\", title, url, extracted_content_plain, full_content_plain) VALUES (new.\"rowid\", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END" + } + ] + } + ] +} \ No newline at end of file diff --git a/apps/weblibre/lib/features/geckoview/domain/repositories/tab.dart b/apps/weblibre/lib/features/geckoview/domain/repositories/tab.dart index cd0b67d2..619928fb 100644 --- a/apps/weblibre/lib/features/geckoview/domain/repositories/tab.dart +++ b/apps/weblibre/lib/features/geckoview/domain/repositories/tab.dart @@ -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 _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 _selectNextTab(String tabId) async { + Future _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 _nearestAvailableVisibleTabByOrder( + String tabId, { + required String? containerId, + required Set excludedTabIds, + }) async { + Future 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 _selectNextTab( + String tabId, { + Set 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 _preservePromotedChildOrderOnClose(List 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 _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), ); }, diff --git a/apps/weblibre/lib/features/geckoview/domain/repositories/tab.g.dart b/apps/weblibre/lib/features/geckoview/domain/repositories/tab.g.dart index a49796a1..96178a72 100644 --- a/apps/weblibre/lib/features/geckoview/domain/repositories/tab.g.dart +++ b/apps/weblibre/lib/features/geckoview/domain/repositories/tab.g.dart @@ -41,7 +41,7 @@ final class TabRepositoryProvider } } -String _$tabRepositoryHash() => r'ce4ac2f2efbb859ba66e98c6e6d21f8205d9b4ed'; +String _$tabRepositoryHash() => r'4c5bda4d0ddcc66cfa420d3c2ca97db42b73b878'; abstract class _$TabRepository extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/entities/tab_view_filter_options.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/entities/tab_view_filter_options.dart index 42e9038c..a1ef186a 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/entities/tab_view_filter_options.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/entities/tab_view_filter_options.dart @@ -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? 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, ]; diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/entities/tab_view_filter_options.g.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/entities/tab_view_filter_options.g.dart index 02e0b0d9..d5bdccc2 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/entities/tab_view_filter_options.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/entities/tab_view_filter_options.g.dart @@ -13,6 +13,8 @@ abstract class _$TabViewFilterOptionsCWProxy { TabViewFilterOptions sortPinnedFirst(bool sortPinnedFirst); + TabViewFilterOptions showHierarchicalTabs(bool showHierarchicalTabs); + TabViewFilterOptions dateRange(DateTimeRange? dateRange); TabViewFilterOptions quickInterval(TabQuickInterval? quickInterval); @@ -28,6 +30,7 @@ abstract class _$TabViewFilterOptionsCWProxy { TabTypeFilter tabTypeFilter, TabSortType sortType, bool sortPinnedFirst, + bool showHierarchicalTabs, DateTimeRange? 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? 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?, ), @@ -137,6 +152,7 @@ Map _$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], }; diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.dart index ca00440f..073b9811 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.dart @@ -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> 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> 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 applyDirection(List 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> seamlessFilteredTabEntities( ); } - return availableTabs; + return EquatableValue(applyDirection(availableTabs.value)); } final searchFiltered = tabSearchResults @@ -624,6 +665,7 @@ EquatableValue> seamlessFilteredTabEntities( } return EquatableValue(searchFiltered); + // Search results retain the search-relevance ordering — no direction flip. } @Riverpod() @@ -671,6 +713,345 @@ EquatableValue> 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> 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 {}, + ), + ); + 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 = >{}; + 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 = []; + 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 = >{}; + 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 byId, + Set visibleIds, +) { + var current = row; + final seen = {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(); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.g.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.g.dart index 30bb3a20..df6c2fc0 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.g.dart @@ -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>, + EquatableValue>, + EquatableValue> + > + with $Provider>> { + /// 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>> $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + EquatableValue> create(Ref ref) { + final argument = this.argument as String?; + return groupedTabListItems(ref, containerId: argument); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(EquatableValue> value) { + return $ProviderOverride( + origin: this, + providerOverride: + $SyncValueProvider>>(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>, + 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._(); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/data/repositories/contextual_toolbar_config_repository.dart b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/data/repositories/contextual_toolbar_config_repository.dart index 551c8f34..8553abc4 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/data/repositories/contextual_toolbar_config_repository.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/data/repositories/contextual_toolbar_config_repository.dart @@ -50,20 +50,30 @@ class ContextualToolbarConfigRepository { return _dao.assignFallback(buttonId, fallbackId); } - Future generateLeadingOrderKey() { - return _dao.generateLeadingOrderKey().getSingle(); + Future generateLeadingOrderKey({required bool isVisible}) { + return _dao.generateLeadingOrderKey(isVisible: isVisible).getSingle(); } - Future generateTrailingOrderKey() { - return _dao.generateTrailingOrderKey().getSingle(); + Future generateTrailingOrderKey({required bool isVisible}) { + return _dao.generateTrailingOrderKey(isVisible: isVisible).getSingle(); } - Future generateOrderKeyAfterButtonId(String buttonId) { - return _dao.generateOrderKeyAfterButtonId(buttonId).getSingleOrNull(); + Future generateOrderKeyAfterButtonId( + String buttonId, { + required bool isVisible, + }) { + return _dao + .generateOrderKeyAfterButtonId(buttonId, isVisible: isVisible) + .getSingleOrNull(); } - Future generateOrderKeyBeforeButtonId(String buttonId) { - return _dao.generateOrderKeyBeforeButtonId(buttonId).getSingle(); + Future generateOrderKeyBeforeButtonId( + String buttonId, { + required bool isVisible, + }) { + return _dao + .generateOrderKeyBeforeButtonId(buttonId, isVisible: isVisible) + .getSingle(); } Future seedMissingDefaults() { diff --git a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart index 1ccb305c..a39d6ab3 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart @@ -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 diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart index 42fbf785..dd1032b9 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart @@ -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? 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 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() { diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.g.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.g.dart index 16997820..f9e552c8 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.g.dart @@ -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> { + /// 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 value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(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 build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref, Set>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, Set>, + Set, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} + @ProviderFor(TabsReorderableController) final tabsReorderableControllerProvider = TabsReorderableControllerProvider._(); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/utils/tab_view_reorder.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/utils/tab_view_reorder.dart new file mode 100644 index 00000000..31b0c5e5 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/utils/tab_view_reorder.dart @@ -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 . + */ +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 movingTabIds; + final String? previousTabId; + final String? nextTabId; + + const TabViewReorderResult({ + required this.movingTabIds, + required this.previousTabId, + required this.nextTabId, + }); +} + +TabViewReorderResult? buildTabViewReorderResult({ + required List visibleItems, + required List treeRows, + required Set 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 = >[]; + final emitted = {}; + 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 = >>{}; + final rootOrder = []; + 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 movingTabIds, + required List 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 items, + int requestedInsertIndex, + String? parentScope, + Map 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 parentById, +) { + return item.tabId == parentScope || parentById[item.tabId] == parentScope; +} + +int? _nearestPreviousScopeAnchorIndex( + List items, + int startIndex, + String? parentScope, + Map parentById, +) { + for (var i = startIndex; i >= 0; i--) { + if (_isDirectScopeAnchor(items[i], parentScope, parentById)) { + return i; + } + } + return null; +} + +int? _nearestNextScopeAnchorIndex( + List items, + int startIndex, + String? parentScope, + Map 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 items, + int anchorIndex, + Map 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 parentById, +) { + var parentId = parentById[tabId]; + final seen = {tabId}; + while (parentId != null) { + if (parentId == ancestorId) { + return true; + } + if (!seen.add(parentId)) { + return false; + } + parentId = parentById[parentId]; + } + return false; +} + +Map> _buildChildrenByParent( + Map rowsById, + Map parentById, +) { + final childrenByParent = >{}; + 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 _subtreeIds( + String rootId, + Map rowsById, + Map> childrenByParent, +) { + final root = rowsById[rootId]; + if (root == null) { + return [rootId]; + } + + final result = []; + final visited = {}; + + void collect(String tabId) { + if (!visited.add(tabId)) return; + + result.add(tabId); + for (final child + in childrenByParent[tabId] ?? const []) { + collect(child.id); + } + } + + collect(root.id); + return result; +} + +String _rootIdFor(String tabId, Map parentById) { + var rootId = tabId; + var parentId = parentById[rootId]; + final seen = {rootId}; + + while (parentId != null && parentById.containsKey(parentId)) { + if (!seen.add(parentId)) { + break; + } + rootId = parentId; + parentId = parentById[rootId]; + } + + return rootId; +} diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart index a62e4e1d..9f50dbd7 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart @@ -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, diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart index e482509e..701d324a 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart @@ -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 treeRows = showHierarchicalTabs + ? ref.watch( + watchTabsWithRootAndDepthProvider( + containerId, + ).select((value) => value.value ?? const []), + ) + : const []; + + final hasActiveSearch = ref.watch( + tabSearchRepositoryProvider( + TabSearchPartition.preview, + ).select((value) => (value.value?.query ?? '').isNotEmpty), + ); + + final List 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([]); final itemCount = - filteredTabEntities.value.length + + primaryRows.length + //Limit to 3 sugegstions for now math.min(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> filteredTabEntities; + final List primaryRows; final EquatableValue> 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; }, ); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_group_expand_toggle.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_group_expand_toggle.dart new file mode 100644 index 00000000..4adfcee9 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_group_expand_toggle.dart @@ -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 . + */ +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), + ], + ), + ), + ), + ), + ), + ), + }; + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart index debc9abd..fdf32ef5 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart @@ -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 treeRows = showHierarchicalTabs + ? ref.watch( + watchTabsWithRootAndDepthProvider( + containerId, + ).select((value) => value.value ?? const []), + ) + : const []; + + final hasActiveSearch = ref.watch( + tabSearchRepositoryProvider( + TabSearchPartition.preview, + ).select((value) => (value.value?.query ?? '').isNotEmpty), + ); + + final List 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([]); final itemCount = - filteredTabEntities.value.length + + primaryRows.length + //Limit to 3 sugegstions for now math.min(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, diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart index 632f2c8e..c8f6ce8d 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart @@ -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(); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart index afcbd613..77c7d266 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart @@ -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( diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_item.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_item.dart new file mode 100644 index 00000000..96f09eba --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_item.dart @@ -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 . + */ +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._(); +} diff --git a/apps/weblibre/lib/features/geckoview/features/preferences/data/repositories/preference_settings.g.dart b/apps/weblibre/lib/features/geckoview/features/preferences/data/repositories/preference_settings.g.dart index 37a44ec6..584a0019 100644 --- a/apps/weblibre/lib/features/geckoview/features/preferences/data/repositories/preference_settings.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/preferences/data/repositories/preference_settings.g.dart @@ -45,7 +45,7 @@ final class StartupPreferenceEnforcementServiceProvider } String _$startupPreferenceEnforcementServiceHash() => - r'5cfe5aec33a9e9e077704ff519c10b0398c5085d'; + r'074e09b7f3abd430946dce87ef1c529597e97866'; abstract class _$StartupPreferenceEnforcementService extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/container.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/container.dart index fc35fd38..dabc5ca4 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/container.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/container.dart @@ -151,6 +151,16 @@ class ContainerDao extends DatabaseAccessor ); } + SingleOrNullSelectable getLastChildTabId( + String? containerId, + String parentId, + ) { + return db.definitionsDrift.lastChildTabId( + containerId: containerId, + parentId: parentId, + ); + } + SingleOrNullSelectable generateOrderKeyAfterTabId( String? containerId, String tabId, diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/tab.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/tab.dart index a7006ee2..1487af8a 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/tab.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/tab.dart @@ -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 deletedIsolationContextIds; @@ -154,35 +154,54 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { Future _generateOrderKey({ required Value parentId, required Value containerId, - required NewTabPosition newTabPosition, + Value 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 upsertTabTransactional( Future Function() createTab, { required Value parentId, - NewTabPosition newTabPosition = NewTabPosition.first, Value containerId = const Value.absent(), Value orderKey = const Value.absent(), + Value afterTabId = const Value.absent(), Value url = const Value.absent(), Value title = const Value.absent(), Value tabMode = const Value.absent(), @@ -194,7 +213,7 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { await _generateOrderKey( parentId: parentId, containerId: containerId, - newTabPosition: newTabPosition, + afterTabId: afterTabId, ); final Value persistedTabMode = tabMode.present ? Value(tabMode.value.toDbValue()) @@ -239,9 +258,9 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { String tabId, { required TabSource source, required Value parentId, - NewTabPosition newTabPosition = NewTabPosition.first, Value containerId = const Value.absent(), Value orderKey = const Value.absent(), + Value afterTabId = const Value.absent(), Value url = const Value.absent(), Value title = const Value.absent(), Value tabMode = const Value.absent(), @@ -252,7 +271,7 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { await _generateOrderKey( parentId: parentId, containerId: containerId, - newTabPosition: newTabPosition, + afterTabId: afterTabId, ); final Value persistedTabMode = tabMode.present ? Value(tabMode.value.toDbValue()) @@ -298,9 +317,265 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { return statement.write(TabCompanion(containerId: Value(containerId))); } - Future assignOrderKey(String id, {required String orderKey}) { - final statement = _updateByIdStatement(id); - return statement.write(TabCompanion(orderKey: Value(orderKey))); + Future reorderTabs({ + required List 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 {} + : { + 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 _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 = []; + for (var i = 0; i < count; i++) { + rank = rank.genPrev(); + reversed.add(rank.value); + } + return reversed.reversed.toList(); + } + + var upperBound = nextRank; + final reversed = []; + 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 preservePromotedChildOrderOnClose(Iterable 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 = >{}; + for (final child in directChildren) { + final parentId = child.parentId; + if (parentId == null) continue; + childrenByParent.putIfAbsent(parentId, () => []).add(child); + } + + final promotedBoundaryCache = >{}; + List promotedBoundaryChildren(String closingTabId) { + return promotedBoundaryCache.putIfAbsent(closingTabId, () { + final result = []; + for (final child + in childrenByParent[closingTabId] ?? const []) { + 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 = []; + + Future 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 touchTab(String id, {required DateTime timestamp}) { @@ -525,6 +800,16 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { ); } + SingleOrNullSelectable lastSubtreeTabIdByOrderKey( + String tabId, { + required String? containerId, + }) { + return db.definitionsDrift.lastSubtreeTabIdByOrderKey( + tabId: tabId, + containerId: containerId, + ); + } + Future> getUnassignedRegularTabsOlderThan(DateTime threshold) { final query = selectOnly(db.tab) ..addColumns([db.tab.id]) diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.dart index 8c752cc5..f8e50044 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.dart @@ -31,7 +31,7 @@ import 'package:weblibre/features/search/domain/fts_tokenizer.dart'; @DriftDatabase(include: {'definitions.drift'}, daos: [ContainerDao, TabDao]) class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin { @override - final int schemaVersion = 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); + }, ); } diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.drift.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.drift.dart index ae254ae7..fc5e08ef 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.drift.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.drift.dart @@ -32,6 +32,7 @@ abstract class $TabDatabase extends i0.GeneratedDatabase { List get allSchemaEntities => [ container, tab, + i1.idxTabParentContainer, tabFts, i1.tabMaintainParentChainOnDelete, i1.tabAfterInsert, diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.steps.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.steps.dart index fa115a95..e012b20b 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.steps.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.steps.dart @@ -722,12 +722,100 @@ i1.GeneratedColumn _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 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 Function(i1.Migrator m, Schema3 schema) from2To3, required Future Function(i1.Migrator m, Schema4 schema) from3To4, required Future Function(i1.Migrator m, Schema5 schema) from4To5, required Future Function(i1.Migrator m, Schema6 schema) from5To6, required Future Function(i1.Migrator m, Schema7 schema) from6To7, + required Future 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 Function(i1.Migrator m, Schema5 schema) from4To5, required Future Function(i1.Migrator m, Schema6 schema) from5To6, required Future Function(i1.Migrator m, Schema7 schema) from6To7, + required Future 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, ), ); diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift index 02f9a6af..cc3a6910 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift @@ -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 diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart index c8bd2763..42ac9f16 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart @@ -2060,6 +2060,11 @@ class TabCompanion extends i0.UpdateCompanion { } } +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, i0.VirtualTableInfo { @override @@ -2406,6 +2411,20 @@ class DefinitionsDrift extends i9.ModularAccessor { ).map((i0.QueryRow row) => row.read('_c0')); } + i0.Selectable 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(parentId), + i0.Variable(containerId), + ], + readsFrom: {tab}, + ).map((i0.QueryRow row) => row.read('id')); + } + i0.Selectable orderKeyAfterTab({ required String? containerId, required String tabId, @@ -2502,6 +2521,35 @@ class DefinitionsDrift extends i9.ModularAccessor { ); } + i0.Selectable 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(containerId)], + readsFrom: {tab}, + ).map( + (i0.QueryRow row) => TabsWithRootAndDepthResult( + id: row.read('id'), + parentId: row.readNullable('parent_id'), + orderKey: row.read('order_key'), + rootId: row.read('root_id'), + depth: row.read('depth'), + ), + ); + } + + i0.Selectable 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(tabId), i0.Variable(containerId)], + readsFrom: {tab}, + ).map((i0.QueryRow row) => row.read('id')); + } + i0.Selectable 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; diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/entities/tab_entity.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/entities/tab_entity.dart index 7b2e07ca..5eb11dc2 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/entities/tab_entity.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/entities/tab_entity.dart @@ -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 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 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 get hashParameters => [ + tabId, + orderKey, + containerId, + parentId, + rootId, + depth, + childCount, + ]; +} + class TabTreeEntity extends TabEntity { @override final String tabId; diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.dart index a932baf5..819b52ec 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.dart @@ -97,6 +97,17 @@ Stream> watchTabTrees(Ref ref) { return db.definitionsDrift.tabTrees().watch(); } +@Riverpod() +Stream> watchTabsWithRootAndDepth( + Ref ref, + String? containerId, +) { + final db = ref.watch(tabDatabaseProvider); + return db.definitionsDrift + .tabsWithRootAndDepth(containerId: containerId) + .watch(); +} + @Riverpod() Stream> watchTabDescendants(Ref ref, String tabId) { final db = ref.watch(tabDatabaseProvider); diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.g.dart index 7e283390..67e953ca 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.g.dart @@ -370,6 +370,89 @@ final class WatchTabTreesProvider String _$watchTabTreesHash() => r'a2be591acb6818ea8675a12d90e6c4684a0448a8'; +@ProviderFor(watchTabsWithRootAndDepth) +final watchTabsWithRootAndDepthProvider = WatchTabsWithRootAndDepthFamily._(); + +final class WatchTabsWithRootAndDepthProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + Stream> + > + with + $FutureModifier>, + $StreamProvider> { + 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> $createElement( + $ProviderPointer pointer, + ) => $StreamProviderElement(pointer); + + @override + Stream> 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>, + 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._(); diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.dart index 3035608a..fb5b734c 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.dart @@ -122,11 +122,19 @@ class TabDataRepository extends _$TabDataRepository { .setPinned(tabId, pinned: pinned); } - Future assignOrderKey(String tabId, String orderKey) { + Future reorderTabs({ + required List movingTabIds, + required String? previousTabId, + required String? nextTabId, + }) { return ref .read(tabDatabaseProvider) .tabDao - .assignOrderKey(tabId, orderKey: orderKey); + .reorderTabs( + movingTabIds: movingTabIds, + previousTabId: previousTabId, + nextTabId: nextTabId, + ); } Future closeAllTabs({ diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart index 39dc6ca4..299d4195 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart @@ -41,7 +41,7 @@ final class TabDataRepositoryProvider } } -String _$tabDataRepositoryHash() => r'e6482b5408aa567f626d2f0a9455b2cc908ba84f'; +String _$tabDataRepositoryHash() => r'e14144f832ce32e1ae349df8970d025036526bc0'; abstract class _$TabDataRepository extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/settings/presentation/screens/browsing_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/browsing_settings.dart index 3de777dd..98b531fb 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/browsing_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/browsing_settings.dart @@ -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), ); }, ), diff --git a/apps/weblibre/lib/features/settings/presentation/screens/contextual_toolbar_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/contextual_toolbar_settings.dart index 080e2971..23952ae6 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/contextual_toolbar_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/contextual_toolbar_settings.dart @@ -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 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 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); diff --git a/apps/weblibre/lib/features/user/data/database/daos/toolbar_button_config.dart b/apps/weblibre/lib/features/user/data/database/daos/toolbar_button_config.dart index 30286789..99567e8e 100644 --- a/apps/weblibre/lib/features/user/data/database/daos/toolbar_button_config.dart +++ b/apps/weblibre/lib/features/user/data/database/daos/toolbar_button_config.dart @@ -44,9 +44,24 @@ class ToolbarButtonConfigDao extends DatabaseAccessor .write(ToolbarButtonConfigsCompanion(orderKey: Value(orderKey))); Future 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 assignFallback(String buttonId, String? fallbackId) => (update(db.toolbarButtonConfigs) @@ -74,7 +89,9 @@ class ToolbarButtonConfigDao extends DatabaseAccessor await transaction(() async { final inserted = []; 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 }); } - SingleSelectable generateLeadingOrderKey({int bucket = 0}) => - db.definitionsDrift.toolbarLeadingOrderKey(bucket: bucket); + SingleSelectable generateLeadingOrderKey({ + int bucket = 0, + required bool isVisible, + }) => db.definitionsDrift.toolbarLeadingOrderKey( + bucket: bucket, + isVisible: isVisible, + ); - SingleSelectable generateTrailingOrderKey({int bucket = 0}) => - db.definitionsDrift.toolbarTrailingOrderKey(bucket: bucket); + SingleSelectable generateTrailingOrderKey({ + int bucket = 0, + required bool isVisible, + }) => db.definitionsDrift.toolbarTrailingOrderKey( + bucket: bucket, + isVisible: isVisible, + ); SingleOrNullSelectable generateOrderKeyAfterButtonId( - String buttonId, - ) => db.definitionsDrift.toolbarOrderKeyAfterButton(buttonId: buttonId); + String buttonId, { + required bool isVisible, + }) => db.definitionsDrift.toolbarOrderKeyAfterButton( + buttonId: buttonId, + isVisible: isVisible, + ); - SingleSelectable generateOrderKeyBeforeButtonId(String buttonId) => - db.definitionsDrift.toolbarOrderKeyBeforeButton(buttonId: buttonId); + SingleSelectable generateOrderKeyBeforeButtonId( + String buttonId, { + required bool isVisible, + }) => db.definitionsDrift.toolbarOrderKeyBeforeButton( + buttonId: buttonId, + isVisible: isVisible, + ); Future _insertWithDeferredFallbacks( List configs, diff --git a/apps/weblibre/lib/features/user/data/database/definitions.drift b/apps/weblibre/lib/features/user/data/database/definitions.drift index 9af1bac9..2d5083ec 100644 --- a/apps/weblibre/lib/features/user/data/database/definitions.drift +++ b/apps/weblibre/lib/features/user/data/database/definitions.drift @@ -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 diff --git a/apps/weblibre/lib/features/user/data/database/definitions.drift.dart b/apps/weblibre/lib/features/user/data/database/definitions.drift.dart index c375ff44..31d8ad64 100644 --- a/apps/weblibre/lib/features/user/data/database/definitions.drift.dart +++ b/apps/weblibre/lib/features/user/data/database/definitions.drift.dart @@ -2024,36 +2024,46 @@ i0.Index get idxToolbarOrderKey => i0.Index( class DefinitionsDrift extends i3.ModularAccessor { DefinitionsDrift(i0.GeneratedDatabase db) : super(db); - i0.Selectable toolbarLeadingOrderKey({required int bucket}) { + i0.Selectable 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(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(bucket), i0.Variable(isVisible)], readsFrom: {toolbarButtonConfigs}, ).map((i0.QueryRow row) => row.read('_c0')); } - i0.Selectable toolbarTrailingOrderKey({required int bucket}) { + i0.Selectable 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(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(bucket), i0.Variable(isVisible)], readsFrom: {toolbarButtonConfigs}, ).map((i0.QueryRow row) => row.read('_c0')); } - i0.Selectable toolbarOrderKeyAfterButton({required String buttonId}) { + i0.Selectable 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(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(isVisible), i0.Variable(buttonId)], readsFrom: {toolbarButtonConfigs}, ).map((i0.QueryRow row) => row.read('_c0')); } i0.Selectable 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(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(isVisible), i0.Variable(buttonId)], readsFrom: {toolbarButtonConfigs}, ).map((i0.QueryRow row) => row.read('_c0')); } diff --git a/apps/weblibre/lib/features/user/data/models/general_settings.dart b/apps/weblibre/lib/features/user/data/models/general_settings.dart index edc46345..418ca28c 100644 --- a/apps/weblibre/lib/features/user/data/models/general_settings.dart +++ b/apps/weblibre/lib/features/user/data/models/general_settings.dart @@ -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 json) => - _$GeneralSettingsFromJson(json); + factory GeneralSettings.fromJson(Map 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 toJson() => _$GeneralSettingsToJson(this); @@ -332,7 +353,8 @@ class GeneralSettings with FastEquatable { showContainerUi, showIsolatedTabUi, storedDefaultCreateTabType, - newTabPosition, + tabListDirection, + tabBarDirection, tabIntentOpenSetting, autoHideTabBar, tabBarSwipeAction, diff --git a/apps/weblibre/lib/features/user/data/models/general_settings.g.dart b/apps/weblibre/lib/features/user/data/models/general_settings.g.dart index 758c5802..7e40f051 100644 --- a/apps/weblibre/lib/features/user/data/models/general_settings.g.dart +++ b/apps/weblibre/lib/features/user/data/models/general_settings.g.dart @@ -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 _$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 = { diff --git a/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart b/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart index 2855361e..5cbe1ffd 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart @@ -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, diff --git a/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart b/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart index 370519c5..f8243a3b 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart @@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider } String _$generalSettingsRepositoryHash() => - r'f75d5cb1963c16404f5fd02db0097b72451717eb'; + r'5fe717f8bccad163fa0cb8ec3294c4b00847e141'; abstract class _$GeneralSettingsRepository extends $StreamNotifier { diff --git a/apps/weblibre/test/drift/tabs/generated/schema.dart b/apps/weblibre/test/drift/tabs/generated/schema.dart index 5f201e00..87fbc35b 100644 --- a/apps/weblibre/test/drift/tabs/generated/schema.dart +++ b/apps/weblibre/test/drift/tabs/generated/schema.dart @@ -10,6 +10,7 @@ import 'schema_v4.dart' as v4; import 'schema_v5.dart' as v5; import 'schema_v6.dart' as v6; import 'schema_v7.dart' as v7; +import 'schema_v8.dart' as v8; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -27,10 +28,12 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v6.DatabaseAtV6(db); case 7: return v7.DatabaseAtV7(db); + case 8: + return v8.DatabaseAtV8(db); default: throw MissingSchemaException(version, versions); } } - static const versions = const [2, 3, 4, 5, 6, 7]; + static const versions = const [2, 3, 4, 5, 6, 7, 8]; } diff --git a/apps/weblibre/test/drift/tabs/generated/schema_v8.dart b/apps/weblibre/test/drift/tabs/generated/schema_v8.dart new file mode 100644 index 00000000..6cf15af7 --- /dev/null +++ b/apps/weblibre/test/drift/tabs/generated/schema_v8.dart @@ -0,0 +1,1397 @@ +// dart format width=80 +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; + +class Container extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Container(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn color = GeneratedColumn( + 'color', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn metadata = GeneratedColumn( + 'metadata', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [id, name, color, metadata]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'container'; + @override + Set get $primaryKey => {id}; + @override + ContainerData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ContainerData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + ), + color: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}color'], + )!, + metadata: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}metadata'], + ), + ); + } + + @override + Container createAlias(String alias) { + return Container(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class ContainerData extends DataClass implements Insertable { + final String id; + final String? name; + final int color; + final String? metadata; + const ContainerData({ + required this.id, + this.name, + required this.color, + this.metadata, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + if (!nullToAbsent || name != null) { + map['name'] = Variable(name); + } + map['color'] = Variable(color); + if (!nullToAbsent || metadata != null) { + map['metadata'] = Variable(metadata); + } + return map; + } + + factory ContainerData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ContainerData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + color: serializer.fromJson(json['color']), + metadata: serializer.fromJson(json['metadata']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'color': serializer.toJson(color), + 'metadata': serializer.toJson(metadata), + }; + } + + ContainerData copyWith({ + String? id, + Value name = const Value.absent(), + int? color, + Value metadata = const Value.absent(), + }) => ContainerData( + id: id ?? this.id, + name: name.present ? name.value : this.name, + color: color ?? this.color, + metadata: metadata.present ? metadata.value : this.metadata, + ); + ContainerData copyWithCompanion(ContainerCompanion data) { + return ContainerData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + color: data.color.present ? data.color.value : this.color, + metadata: data.metadata.present ? data.metadata.value : this.metadata, + ); + } + + @override + String toString() { + return (StringBuffer('ContainerData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('color: $color, ') + ..write('metadata: $metadata') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, name, color, metadata); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ContainerData && + other.id == this.id && + other.name == this.name && + other.color == this.color && + other.metadata == this.metadata); +} + +class ContainerCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value color; + final Value metadata; + final Value rowid; + const ContainerCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.color = const Value.absent(), + this.metadata = const Value.absent(), + this.rowid = const Value.absent(), + }); + ContainerCompanion.insert({ + required String id, + this.name = const Value.absent(), + required int color, + this.metadata = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + color = Value(color); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? color, + Expression? metadata, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (color != null) 'color': color, + if (metadata != null) 'metadata': metadata, + if (rowid != null) 'rowid': rowid, + }); + } + + ContainerCompanion copyWith({ + Value? id, + Value? name, + Value? color, + Value? metadata, + Value? rowid, + }) { + return ContainerCompanion( + id: id ?? this.id, + name: name ?? this.name, + color: color ?? this.color, + metadata: metadata ?? this.metadata, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (color.present) { + map['color'] = Variable(color.value); + } + if (metadata.present) { + map['metadata'] = Variable(metadata.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ContainerCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('color: $color, ') + ..write('metadata: $metadata, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Tab extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Tab(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn source = GeneratedColumn( + 'source', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'REFERENCES tab(id)ON DELETE SET NULL', + ); + late final GeneratedColumn containerId = GeneratedColumn( + 'container_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'REFERENCES container(id)ON DELETE CASCADE', + ); + late final GeneratedColumn orderKey = GeneratedColumn( + 'order_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn tabMode = GeneratedColumn( + 'tab_mode', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn isolationContextId = + GeneratedColumn( + 'isolation_context_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn isPinned = GeneratedColumn( + 'is_pinned', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn isProbablyReaderable = GeneratedColumn( + 'is_probably_readerable', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentMarkdown = + GeneratedColumn( + 'extracted_content_markdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentPlain = + GeneratedColumn( + 'extracted_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentMarkdown = + GeneratedColumn( + 'full_content_markdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentPlain = GeneratedColumn( + 'full_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn timestamp = GeneratedColumn( + 'timestamp', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [ + id, + source, + parentId, + containerId, + orderKey, + url, + title, + tabMode, + isolationContextId, + isPinned, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + timestamp, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tab'; + @override + Set get $primaryKey => {id}; + @override + TabData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TabData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + source: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}source'], + )!, + parentId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}parent_id'], + ), + containerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}container_id'], + ), + orderKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}order_key'], + )!, + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + ), + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + tabMode: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}tab_mode'], + )!, + isolationContextId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}isolation_context_id'], + ), + isPinned: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}is_pinned'], + )!, + isProbablyReaderable: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}is_probably_readerable'], + ), + extractedContentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_markdown'], + ), + extractedContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + ), + fullContentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_markdown'], + ), + fullContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + ), + timestamp: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}timestamp'], + )!, + ); + } + + @override + Tab createAlias(String alias) { + return Tab(attachedDatabase, alias); + } + + @override + List get customConstraints => const [ + 'CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))', + ]; + @override + bool get dontWriteConstraints => true; +} + +class TabData extends DataClass implements Insertable { + final String id; + final int source; + final String? parentId; + final String? containerId; + final String orderKey; + final String? url; + final String? title; + final int tabMode; + final String? isolationContextId; + final int isPinned; + final int? isProbablyReaderable; + final String? extractedContentMarkdown; + final String? extractedContentPlain; + final String? fullContentMarkdown; + final String? fullContentPlain; + final int timestamp; + const TabData({ + required this.id, + required this.source, + this.parentId, + this.containerId, + required this.orderKey, + this.url, + this.title, + required this.tabMode, + this.isolationContextId, + required this.isPinned, + this.isProbablyReaderable, + this.extractedContentMarkdown, + this.extractedContentPlain, + this.fullContentMarkdown, + this.fullContentPlain, + required this.timestamp, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['source'] = Variable(source); + if (!nullToAbsent || parentId != null) { + map['parent_id'] = Variable(parentId); + } + if (!nullToAbsent || containerId != null) { + map['container_id'] = Variable(containerId); + } + map['order_key'] = Variable(orderKey); + if (!nullToAbsent || url != null) { + map['url'] = Variable(url); + } + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + map['tab_mode'] = Variable(tabMode); + if (!nullToAbsent || isolationContextId != null) { + map['isolation_context_id'] = Variable(isolationContextId); + } + map['is_pinned'] = Variable(isPinned); + if (!nullToAbsent || isProbablyReaderable != null) { + map['is_probably_readerable'] = Variable(isProbablyReaderable); + } + if (!nullToAbsent || extractedContentMarkdown != null) { + map['extracted_content_markdown'] = Variable( + extractedContentMarkdown, + ); + } + if (!nullToAbsent || extractedContentPlain != null) { + map['extracted_content_plain'] = Variable(extractedContentPlain); + } + if (!nullToAbsent || fullContentMarkdown != null) { + map['full_content_markdown'] = Variable(fullContentMarkdown); + } + if (!nullToAbsent || fullContentPlain != null) { + map['full_content_plain'] = Variable(fullContentPlain); + } + map['timestamp'] = Variable(timestamp); + return map; + } + + factory TabData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TabData( + id: serializer.fromJson(json['id']), + source: serializer.fromJson(json['source']), + parentId: serializer.fromJson(json['parentId']), + containerId: serializer.fromJson(json['containerId']), + orderKey: serializer.fromJson(json['orderKey']), + url: serializer.fromJson(json['url']), + title: serializer.fromJson(json['title']), + tabMode: serializer.fromJson(json['tabMode']), + isolationContextId: serializer.fromJson( + json['isolationContextId'], + ), + isPinned: serializer.fromJson(json['isPinned']), + isProbablyReaderable: serializer.fromJson( + json['isProbablyReaderable'], + ), + extractedContentMarkdown: serializer.fromJson( + json['extractedContentMarkdown'], + ), + extractedContentPlain: serializer.fromJson( + json['extractedContentPlain'], + ), + fullContentMarkdown: serializer.fromJson( + json['fullContentMarkdown'], + ), + fullContentPlain: serializer.fromJson(json['fullContentPlain']), + timestamp: serializer.fromJson(json['timestamp']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'source': serializer.toJson(source), + 'parentId': serializer.toJson(parentId), + 'containerId': serializer.toJson(containerId), + 'orderKey': serializer.toJson(orderKey), + 'url': serializer.toJson(url), + 'title': serializer.toJson(title), + 'tabMode': serializer.toJson(tabMode), + 'isolationContextId': serializer.toJson(isolationContextId), + 'isPinned': serializer.toJson(isPinned), + 'isProbablyReaderable': serializer.toJson(isProbablyReaderable), + 'extractedContentMarkdown': serializer.toJson( + extractedContentMarkdown, + ), + 'extractedContentPlain': serializer.toJson( + extractedContentPlain, + ), + 'fullContentMarkdown': serializer.toJson(fullContentMarkdown), + 'fullContentPlain': serializer.toJson(fullContentPlain), + 'timestamp': serializer.toJson(timestamp), + }; + } + + TabData copyWith({ + String? id, + int? source, + Value parentId = const Value.absent(), + Value containerId = const Value.absent(), + String? orderKey, + Value url = const Value.absent(), + Value title = const Value.absent(), + int? tabMode, + Value isolationContextId = const Value.absent(), + int? isPinned, + Value isProbablyReaderable = const Value.absent(), + Value extractedContentMarkdown = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentMarkdown = const Value.absent(), + Value fullContentPlain = const Value.absent(), + int? timestamp, + }) => TabData( + id: id ?? this.id, + source: source ?? this.source, + parentId: parentId.present ? parentId.value : this.parentId, + containerId: containerId.present ? containerId.value : this.containerId, + orderKey: orderKey ?? this.orderKey, + url: url.present ? url.value : this.url, + title: title.present ? title.value : this.title, + tabMode: tabMode ?? this.tabMode, + isolationContextId: isolationContextId.present + ? isolationContextId.value + : this.isolationContextId, + isPinned: isPinned ?? this.isPinned, + isProbablyReaderable: isProbablyReaderable.present + ? isProbablyReaderable.value + : this.isProbablyReaderable, + extractedContentMarkdown: extractedContentMarkdown.present + ? extractedContentMarkdown.value + : this.extractedContentMarkdown, + extractedContentPlain: extractedContentPlain.present + ? extractedContentPlain.value + : this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown.present + ? fullContentMarkdown.value + : this.fullContentMarkdown, + fullContentPlain: fullContentPlain.present + ? fullContentPlain.value + : this.fullContentPlain, + timestamp: timestamp ?? this.timestamp, + ); + TabData copyWithCompanion(TabCompanion data) { + return TabData( + id: data.id.present ? data.id.value : this.id, + source: data.source.present ? data.source.value : this.source, + parentId: data.parentId.present ? data.parentId.value : this.parentId, + containerId: data.containerId.present + ? data.containerId.value + : this.containerId, + orderKey: data.orderKey.present ? data.orderKey.value : this.orderKey, + url: data.url.present ? data.url.value : this.url, + title: data.title.present ? data.title.value : this.title, + tabMode: data.tabMode.present ? data.tabMode.value : this.tabMode, + isolationContextId: data.isolationContextId.present + ? data.isolationContextId.value + : this.isolationContextId, + isPinned: data.isPinned.present ? data.isPinned.value : this.isPinned, + isProbablyReaderable: data.isProbablyReaderable.present + ? data.isProbablyReaderable.value + : this.isProbablyReaderable, + extractedContentMarkdown: data.extractedContentMarkdown.present + ? data.extractedContentMarkdown.value + : this.extractedContentMarkdown, + extractedContentPlain: data.extractedContentPlain.present + ? data.extractedContentPlain.value + : this.extractedContentPlain, + fullContentMarkdown: data.fullContentMarkdown.present + ? data.fullContentMarkdown.value + : this.fullContentMarkdown, + fullContentPlain: data.fullContentPlain.present + ? data.fullContentPlain.value + : this.fullContentPlain, + timestamp: data.timestamp.present ? data.timestamp.value : this.timestamp, + ); + } + + @override + String toString() { + return (StringBuffer('TabData(') + ..write('id: $id, ') + ..write('source: $source, ') + ..write('parentId: $parentId, ') + ..write('containerId: $containerId, ') + ..write('orderKey: $orderKey, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('tabMode: $tabMode, ') + ..write('isolationContextId: $isolationContextId, ') + ..write('isPinned: $isPinned, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('timestamp: $timestamp') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + source, + parentId, + containerId, + orderKey, + url, + title, + tabMode, + isolationContextId, + isPinned, + isProbablyReaderable, + extractedContentMarkdown, + extractedContentPlain, + fullContentMarkdown, + fullContentPlain, + timestamp, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TabData && + other.id == this.id && + other.source == this.source && + other.parentId == this.parentId && + other.containerId == this.containerId && + other.orderKey == this.orderKey && + other.url == this.url && + other.title == this.title && + other.tabMode == this.tabMode && + other.isolationContextId == this.isolationContextId && + other.isPinned == this.isPinned && + other.isProbablyReaderable == this.isProbablyReaderable && + other.extractedContentMarkdown == this.extractedContentMarkdown && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentMarkdown == this.fullContentMarkdown && + other.fullContentPlain == this.fullContentPlain && + other.timestamp == this.timestamp); +} + +class TabCompanion extends UpdateCompanion { + final Value id; + final Value source; + final Value parentId; + final Value containerId; + final Value orderKey; + final Value url; + final Value title; + final Value tabMode; + final Value isolationContextId; + final Value isPinned; + final Value isProbablyReaderable; + final Value extractedContentMarkdown; + final Value extractedContentPlain; + final Value fullContentMarkdown; + final Value fullContentPlain; + final Value timestamp; + final Value rowid; + const TabCompanion({ + this.id = const Value.absent(), + this.source = const Value.absent(), + this.parentId = const Value.absent(), + this.containerId = const Value.absent(), + this.orderKey = const Value.absent(), + this.url = const Value.absent(), + this.title = const Value.absent(), + this.tabMode = const Value.absent(), + this.isolationContextId = const Value.absent(), + this.isPinned = const Value.absent(), + this.isProbablyReaderable = const Value.absent(), + this.extractedContentMarkdown = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentMarkdown = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.timestamp = const Value.absent(), + this.rowid = const Value.absent(), + }); + TabCompanion.insert({ + required String id, + required int source, + this.parentId = const Value.absent(), + this.containerId = const Value.absent(), + required String orderKey, + this.url = const Value.absent(), + this.title = const Value.absent(), + this.tabMode = const Value.absent(), + this.isolationContextId = const Value.absent(), + this.isPinned = const Value.absent(), + this.isProbablyReaderable = const Value.absent(), + this.extractedContentMarkdown = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentMarkdown = const Value.absent(), + this.fullContentPlain = const Value.absent(), + required int timestamp, + this.rowid = const Value.absent(), + }) : id = Value(id), + source = Value(source), + orderKey = Value(orderKey), + timestamp = Value(timestamp); + static Insertable custom({ + Expression? id, + Expression? source, + Expression? parentId, + Expression? containerId, + Expression? orderKey, + Expression? url, + Expression? title, + Expression? tabMode, + Expression? isolationContextId, + Expression? isPinned, + Expression? isProbablyReaderable, + Expression? extractedContentMarkdown, + Expression? extractedContentPlain, + Expression? fullContentMarkdown, + Expression? fullContentPlain, + Expression? timestamp, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (source != null) 'source': source, + if (parentId != null) 'parent_id': parentId, + if (containerId != null) 'container_id': containerId, + if (orderKey != null) 'order_key': orderKey, + if (url != null) 'url': url, + if (title != null) 'title': title, + if (tabMode != null) 'tab_mode': tabMode, + if (isolationContextId != null) + 'isolation_context_id': isolationContextId, + if (isPinned != null) 'is_pinned': isPinned, + if (isProbablyReaderable != null) + 'is_probably_readerable': isProbablyReaderable, + if (extractedContentMarkdown != null) + 'extracted_content_markdown': extractedContentMarkdown, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentMarkdown != null) + 'full_content_markdown': fullContentMarkdown, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (timestamp != null) 'timestamp': timestamp, + if (rowid != null) 'rowid': rowid, + }); + } + + TabCompanion copyWith({ + Value? id, + Value? source, + Value? parentId, + Value? containerId, + Value? orderKey, + Value? url, + Value? title, + Value? tabMode, + Value? isolationContextId, + Value? isPinned, + Value? isProbablyReaderable, + Value? extractedContentMarkdown, + Value? extractedContentPlain, + Value? fullContentMarkdown, + Value? fullContentPlain, + Value? timestamp, + Value? rowid, + }) { + return TabCompanion( + id: id ?? this.id, + source: source ?? this.source, + parentId: parentId ?? this.parentId, + containerId: containerId ?? this.containerId, + orderKey: orderKey ?? this.orderKey, + url: url ?? this.url, + title: title ?? this.title, + tabMode: tabMode ?? this.tabMode, + isolationContextId: isolationContextId ?? this.isolationContextId, + isPinned: isPinned ?? this.isPinned, + isProbablyReaderable: isProbablyReaderable ?? this.isProbablyReaderable, + extractedContentMarkdown: + extractedContentMarkdown ?? this.extractedContentMarkdown, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentMarkdown: fullContentMarkdown ?? this.fullContentMarkdown, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + timestamp: timestamp ?? this.timestamp, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (source.present) { + map['source'] = Variable(source.value); + } + if (parentId.present) { + map['parent_id'] = Variable(parentId.value); + } + if (containerId.present) { + map['container_id'] = Variable(containerId.value); + } + if (orderKey.present) { + map['order_key'] = Variable(orderKey.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (tabMode.present) { + map['tab_mode'] = Variable(tabMode.value); + } + if (isolationContextId.present) { + map['isolation_context_id'] = Variable(isolationContextId.value); + } + if (isPinned.present) { + map['is_pinned'] = Variable(isPinned.value); + } + if (isProbablyReaderable.present) { + map['is_probably_readerable'] = Variable(isProbablyReaderable.value); + } + if (extractedContentMarkdown.present) { + map['extracted_content_markdown'] = Variable( + extractedContentMarkdown.value, + ); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = Variable( + extractedContentPlain.value, + ); + } + if (fullContentMarkdown.present) { + map['full_content_markdown'] = Variable( + fullContentMarkdown.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = Variable(fullContentPlain.value); + } + if (timestamp.present) { + map['timestamp'] = Variable(timestamp.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TabCompanion(') + ..write('id: $id, ') + ..write('source: $source, ') + ..write('parentId: $parentId, ') + ..write('containerId: $containerId, ') + ..write('orderKey: $orderKey, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('tabMode: $tabMode, ') + ..write('isolationContextId: $isolationContextId, ') + ..write('isPinned: $isPinned, ') + ..write('isProbablyReaderable: $isProbablyReaderable, ') + ..write('extractedContentMarkdown: $extractedContentMarkdown, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentMarkdown: $fullContentMarkdown, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('timestamp: $timestamp, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class TabFts extends Table + with TableInfo, VirtualTableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + TabFts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn extractedContentPlain = + GeneratedColumn( + 'extracted_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn fullContentPlain = GeneratedColumn( + 'full_content_plain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [ + title, + url, + extractedContentPlain, + fullContentPlain, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tab_fts'; + @override + Set get $primaryKey => const {}; + @override + TabFtsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TabFtsData( + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + ), + extractedContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}extracted_content_plain'], + ), + fullContentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}full_content_plain'], + ), + ); + } + + @override + TabFts createAlias(String alias) { + return TabFts(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; + @override + String get moduleAndArgs => + 'fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize="trigram")'; +} + +class TabFtsData extends DataClass implements Insertable { + final String? title; + final String? url; + final String? extractedContentPlain; + final String? fullContentPlain; + const TabFtsData({ + this.title, + this.url, + this.extractedContentPlain, + this.fullContentPlain, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + if (!nullToAbsent || url != null) { + map['url'] = Variable(url); + } + if (!nullToAbsent || extractedContentPlain != null) { + map['extracted_content_plain'] = Variable(extractedContentPlain); + } + if (!nullToAbsent || fullContentPlain != null) { + map['full_content_plain'] = Variable(fullContentPlain); + } + return map; + } + + factory TabFtsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TabFtsData( + title: serializer.fromJson(json['title']), + url: serializer.fromJson(json['url']), + extractedContentPlain: serializer.fromJson( + json['extractedContentPlain'], + ), + fullContentPlain: serializer.fromJson(json['fullContentPlain']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'title': serializer.toJson(title), + 'url': serializer.toJson(url), + 'extractedContentPlain': serializer.toJson( + extractedContentPlain, + ), + 'fullContentPlain': serializer.toJson(fullContentPlain), + }; + } + + TabFtsData copyWith({ + Value title = const Value.absent(), + Value url = const Value.absent(), + Value extractedContentPlain = const Value.absent(), + Value fullContentPlain = const Value.absent(), + }) => TabFtsData( + title: title.present ? title.value : this.title, + url: url.present ? url.value : this.url, + extractedContentPlain: extractedContentPlain.present + ? extractedContentPlain.value + : this.extractedContentPlain, + fullContentPlain: fullContentPlain.present + ? fullContentPlain.value + : this.fullContentPlain, + ); + TabFtsData copyWithCompanion(TabFtsCompanion data) { + return TabFtsData( + title: data.title.present ? data.title.value : this.title, + url: data.url.present ? data.url.value : this.url, + extractedContentPlain: data.extractedContentPlain.present + ? data.extractedContentPlain.value + : this.extractedContentPlain, + fullContentPlain: data.fullContentPlain.present + ? data.fullContentPlain.value + : this.fullContentPlain, + ); + } + + @override + String toString() { + return (StringBuffer('TabFtsData(') + ..write('title: $title, ') + ..write('url: $url, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(title, url, extractedContentPlain, fullContentPlain); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TabFtsData && + other.title == this.title && + other.url == this.url && + other.extractedContentPlain == this.extractedContentPlain && + other.fullContentPlain == this.fullContentPlain); +} + +class TabFtsCompanion extends UpdateCompanion { + final Value title; + final Value url; + final Value extractedContentPlain; + final Value fullContentPlain; + final Value rowid; + const TabFtsCompanion({ + this.title = const Value.absent(), + this.url = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + TabFtsCompanion.insert({ + this.title = const Value.absent(), + this.url = const Value.absent(), + this.extractedContentPlain = const Value.absent(), + this.fullContentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? title, + Expression? url, + Expression? extractedContentPlain, + Expression? fullContentPlain, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (title != null) 'title': title, + if (url != null) 'url': url, + if (extractedContentPlain != null) + 'extracted_content_plain': extractedContentPlain, + if (fullContentPlain != null) 'full_content_plain': fullContentPlain, + if (rowid != null) 'rowid': rowid, + }); + } + + TabFtsCompanion copyWith({ + Value? title, + Value? url, + Value? extractedContentPlain, + Value? fullContentPlain, + Value? rowid, + }) { + return TabFtsCompanion( + title: title ?? this.title, + url: url ?? this.url, + extractedContentPlain: + extractedContentPlain ?? this.extractedContentPlain, + fullContentPlain: fullContentPlain ?? this.fullContentPlain, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (title.present) { + map['title'] = Variable(title.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (extractedContentPlain.present) { + map['extracted_content_plain'] = Variable( + extractedContentPlain.value, + ); + } + if (fullContentPlain.present) { + map['full_content_plain'] = Variable(fullContentPlain.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TabFtsCompanion(') + ..write('title: $title, ') + ..write('url: $url, ') + ..write('extractedContentPlain: $extractedContentPlain, ') + ..write('fullContentPlain: $fullContentPlain, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV8 extends GeneratedDatabase { + DatabaseAtV8(QueryExecutor e) : super(e); + late final Container container = Container(this); + late final Tab tab = Tab(this); + late final Index idxTabParentContainer = Index( + 'idx_tab_parent_container', + 'CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)', + ); + late final TabFts tabFts = TabFts(this); + late final Trigger tabMaintainParentChainOnDelete = Trigger( + 'CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END', + 'tab_maintain_parent_chain_on_delete', + ); + late final Trigger tabAfterInsert = Trigger( + 'CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_insert', + ); + late final Trigger tabAfterDelete = Trigger( + 'CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END', + 'tab_after_delete', + ); + late final Trigger tabAfterUpdate = Trigger( + 'CREATE TRIGGER tab_after_update AFTER UPDATE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END', + 'tab_after_update', + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + container, + tab, + idxTabParentContainer, + tabFts, + tabMaintainParentChainOnDelete, + tabAfterInsert, + tabAfterDelete, + tabAfterUpdate, + ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'container', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.insert, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tab', + limitUpdateKind: UpdateKind.update, + ), + result: [TableUpdate('tab_fts', kind: UpdateKind.insert)], + ), + ]); + @override + int get schemaVersion => 8; +}