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