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_list.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.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/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/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_mode.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_source.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_source.dart';
|
||||||
@@ -68,14 +69,6 @@ class TabRepository extends _$TabRepository {
|
|||||||
_tabFromIntent.remove(tabId);
|
_tabFromIntent.remove(tabId);
|
||||||
}
|
}
|
||||||
|
|
||||||
NewTabPosition _newTabPositionForParent(String? parentId) {
|
|
||||||
if (parentId != null) {
|
|
||||||
return NewTabPosition.first;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ref.read(generalSettingsWithDefaultsProvider).newTabPosition;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String?> _resolveParentIdForContext({
|
Future<String?> _resolveParentIdForContext({
|
||||||
required String? parentId,
|
required String? parentId,
|
||||||
required String? targetContextId,
|
required String? targetContextId,
|
||||||
@@ -132,7 +125,6 @@ class TabRepository extends _$TabRepository {
|
|||||||
final effectiveContextId = tabMode is IsolatedTabMode
|
final effectiveContextId = tabMode is IsolatedTabMode
|
||||||
? effectiveIsolationContextId
|
? effectiveIsolationContextId
|
||||||
: assignedContainer?.metadata.contextualIdentity;
|
: assignedContainer?.metadata.contextualIdentity;
|
||||||
final newTabPosition = _newTabPositionForParent(validatedParentId);
|
|
||||||
|
|
||||||
final newTabId = await tabDao.upsertTabTransactional(
|
final newTabId = await tabDao.upsertTabTransactional(
|
||||||
() {
|
() {
|
||||||
@@ -150,7 +142,6 @@ class TabRepository extends _$TabRepository {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
parentId: Value(validatedParentId),
|
parentId: Value(validatedParentId),
|
||||||
newTabPosition: newTabPosition,
|
|
||||||
containerId: Value(assignedContainer?.id),
|
containerId: Value(assignedContainer?.id),
|
||||||
url: Value(url),
|
url: Value(url),
|
||||||
tabMode: Value(tabMode),
|
tabMode: Value(tabMode),
|
||||||
@@ -215,7 +206,6 @@ class TabRepository extends _$TabRepository {
|
|||||||
tabId,
|
tabId,
|
||||||
parentId: Value(validatedParentId),
|
parentId: Value(validatedParentId),
|
||||||
source: TabSource.manual,
|
source: TabSource.manual,
|
||||||
newTabPosition: _newTabPositionForParent(validatedParentId),
|
|
||||||
containerId: Value(assignedContainer?.id),
|
containerId: Value(assignedContainer?.id),
|
||||||
url: Value(Uri.tryParse(tab.url)),
|
url: Value(Uri.tryParse(tab.url)),
|
||||||
tabMode: Value(
|
tabMode: Value(
|
||||||
@@ -257,6 +247,22 @@ class TabRepository extends _$TabRepository {
|
|||||||
? duplicateIsolationContextId
|
? duplicateIsolationContextId
|
||||||
: containerData?.metadata.contextualIdentity;
|
: 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 await tabDao.upsertTabTransactional(
|
||||||
() {
|
() {
|
||||||
return _tabsService.duplicateTab(
|
return _tabsService.duplicateTab(
|
||||||
@@ -265,8 +271,8 @@ class TabRepository extends _$TabRepository {
|
|||||||
selectNewTab: selectTab,
|
selectNewTab: selectTab,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
parentId: const Value.absent(),
|
parentId: Value(sourceParentId),
|
||||||
newTabPosition: _newTabPositionForParent(null),
|
afterTabId: Value(anchorTabId),
|
||||||
containerId: Value(containerData?.id),
|
containerId: Value(containerData?.id),
|
||||||
tabMode: Value(duplicateTabMode),
|
tabMode: Value(duplicateTabMode),
|
||||||
);
|
);
|
||||||
@@ -319,15 +325,12 @@ class TabRepository extends _$TabRepository {
|
|||||||
String? containerId,
|
String? containerId,
|
||||||
bool skipContainerCheck = true,
|
bool skipContainerCheck = true,
|
||||||
}) async {
|
}) async {
|
||||||
final previousTabId = await ref
|
final previousTabId = await _adjacentVisibleTabByOrder(
|
||||||
.read(tabDatabaseProvider)
|
tabId,
|
||||||
.definitionsDrift
|
containerId: containerId,
|
||||||
.previousTabByOrderKey(
|
skipContainerCheck: skipContainerCheck,
|
||||||
tabId: tabId,
|
selectPrevious: true,
|
||||||
containerId: containerId,
|
);
|
||||||
skipContainerCheck: skipContainerCheck,
|
|
||||||
)
|
|
||||||
.getSingleOrNull();
|
|
||||||
|
|
||||||
if (ref.mounted && previousTabId != null) {
|
if (ref.mounted && previousTabId != null) {
|
||||||
return selectTab(previousTabId);
|
return selectTab(previousTabId);
|
||||||
@@ -341,15 +344,12 @@ class TabRepository extends _$TabRepository {
|
|||||||
String? containerId,
|
String? containerId,
|
||||||
bool skipContainerCheck = true,
|
bool skipContainerCheck = true,
|
||||||
}) async {
|
}) async {
|
||||||
final previousTabId = await ref
|
final previousTabId = await _adjacentVisibleTabByOrder(
|
||||||
.read(tabDatabaseProvider)
|
tabId,
|
||||||
.definitionsDrift
|
containerId: containerId,
|
||||||
.nextTabByOrderKey(
|
skipContainerCheck: skipContainerCheck,
|
||||||
tabId: tabId,
|
selectPrevious: false,
|
||||||
containerId: containerId,
|
);
|
||||||
skipContainerCheck: skipContainerCheck,
|
|
||||||
)
|
|
||||||
.getSingleOrNull();
|
|
||||||
|
|
||||||
if (ref.mounted && previousTabId != null) {
|
if (ref.mounted && previousTabId != null) {
|
||||||
return selectTab(previousTabId);
|
return selectTab(previousTabId);
|
||||||
@@ -383,7 +383,80 @@ class TabRepository extends _$TabRepository {
|
|||||||
return true;
|
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 tabState = ref.read(tabStatesProvider)[tabId];
|
||||||
|
|
||||||
final currentContainerId = await ref
|
final currentContainerId = await ref
|
||||||
@@ -395,13 +468,20 @@ class TabRepository extends _$TabRepository {
|
|||||||
final sameContainerTabs = await ref
|
final sameContainerTabs = await ref
|
||||||
.read(containerRepositoryProvider.notifier)
|
.read(containerRepositoryProvider.notifier)
|
||||||
.getContainerTabIds(currentContainerId)
|
.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;
|
if (!ref.mounted) return;
|
||||||
|
|
||||||
// Priority 1: Check for parent tab first
|
// Priority 1: Check for parent tab first
|
||||||
if (tabState?.parentId != null) {
|
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
|
// Priority 2: Check for previous tab by timestamp
|
||||||
@@ -419,34 +499,14 @@ class TabRepository extends _$TabRepository {
|
|||||||
|
|
||||||
if (!ref.mounted) return;
|
if (!ref.mounted) return;
|
||||||
|
|
||||||
final previousOrderedTabId = await ref
|
final orderedNeighborTabId = await _nearestAvailableVisibleTabByOrder(
|
||||||
.read(tabDatabaseProvider)
|
tabId,
|
||||||
.definitionsDrift
|
containerId: currentContainerId,
|
||||||
.previousTabByOrderKey(
|
excludedTabIds: excludedTabIds,
|
||||||
tabId: tabId,
|
);
|
||||||
containerId: currentContainerId,
|
|
||||||
skipContainerCheck: false,
|
|
||||||
)
|
|
||||||
.getSingleOrNull();
|
|
||||||
|
|
||||||
if (previousOrderedTabId != null) {
|
if (orderedNeighborTabId != null) {
|
||||||
return _tabsService.selectTab(tabId: previousOrderedTabId);
|
return _tabsService.selectTab(tabId: orderedNeighborTabId);
|
||||||
}
|
|
||||||
|
|
||||||
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 (!ref.mounted) return;
|
if (!ref.mounted) return;
|
||||||
@@ -454,7 +514,11 @@ class TabRepository extends _$TabRepository {
|
|||||||
final unassignedTabs = await ref
|
final unassignedTabs = await ref
|
||||||
.read(containerRepositoryProvider.notifier)
|
.read(containerRepositoryProvider.notifier)
|
||||||
.getContainerTabIds(null)
|
.getContainerTabIds(null)
|
||||||
.then((tabs) => tabs.where((tab) => tab != tabId).toList());
|
.then(
|
||||||
|
(tabs) => tabs
|
||||||
|
.where((tab) => tab != tabId && !excludedTabIds.contains(tab))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
if (unassignedTabs.isNotEmpty) {
|
if (unassignedTabs.isNotEmpty) {
|
||||||
return _tabsService.selectTab(tabId: unassignedTabs.first);
|
return _tabsService.selectTab(tabId: unassignedTabs.first);
|
||||||
@@ -474,7 +538,11 @@ class TabRepository extends _$TabRepository {
|
|||||||
(container) => ref
|
(container) => ref
|
||||||
.read(containerRepositoryProvider.notifier)
|
.read(containerRepositoryProvider.notifier)
|
||||||
.getContainerTabIds(container.id)
|
.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) {
|
if (nextContainerTabs.isNotEmpty) {
|
||||||
@@ -493,6 +561,8 @@ class TabRepository extends _$TabRepository {
|
|||||||
await _selectNextTab(tabId);
|
await _selectNextTab(tabId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await _preservePromotedChildOrderOnClose([tabId]);
|
||||||
|
|
||||||
await _tabsService.removeTab(tabId: tabId);
|
await _tabsService.removeTab(tabId: tabId);
|
||||||
|
|
||||||
// Queue isolation cleanup — actual cleanup runs after syncTabs
|
// Queue isolation cleanup — actual cleanup runs after syncTabs
|
||||||
@@ -517,13 +587,22 @@ class TabRepository extends _$TabRepository {
|
|||||||
|
|
||||||
final selectedTab = ref.read(selectedTabProvider);
|
final selectedTab = ref.read(selectedTabProvider);
|
||||||
if (selectedTab.mapNotNull(tabIds.contains) ?? false) {
|
if (selectedTab.mapNotNull(tabIds.contains) ?? false) {
|
||||||
await _selectNextTab(selectedTab!);
|
await _selectNextTab(selectedTab!, excludedTabIds: tabIds.toSet());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await _preservePromotedChildOrderOnClose(tabIds);
|
||||||
|
|
||||||
await _tabsService.removeTabs(ids: 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
|
/// Clears Gecko browsing data and removes proxy alias for an isolation
|
||||||
/// context if no more tabs share it.
|
/// context if no more tabs share it.
|
||||||
Future<void> _cleanupIsolationContextIfEmpty(String contextId) async {
|
Future<void> _cleanupIsolationContextIfEmpty(String contextId) async {
|
||||||
@@ -638,7 +717,6 @@ class TabRepository extends _$TabRepository {
|
|||||||
tabId,
|
tabId,
|
||||||
parentId: const Value.absent(),
|
parentId: const Value.absent(),
|
||||||
source: TabSource.addedEvent,
|
source: TabSource.addedEvent,
|
||||||
newTabPosition: _newTabPositionForParent(null),
|
|
||||||
containerId: Value(containerId),
|
containerId: Value(containerId),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ final class TabRepositoryProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$tabRepositoryHash() => r'ce4ac2f2efbb859ba66e98c6e6d21f8205d9b4ed';
|
String _$tabRepositoryHash() => r'4c5bda4d0ddcc66cfa420d3c2ca97db42b73b878';
|
||||||
|
|
||||||
abstract class _$TabRepository extends $Notifier<void> {
|
abstract class _$TabRepository extends $Notifier<void> {
|
||||||
void build();
|
void build();
|
||||||
|
|||||||
+5
@@ -86,6 +86,8 @@ class TabViewFilterOptions with FastEquatable {
|
|||||||
final TabTypeFilter tabTypeFilter;
|
final TabTypeFilter tabTypeFilter;
|
||||||
final TabSortType sortType;
|
final TabSortType sortType;
|
||||||
final bool sortPinnedFirst;
|
final bool sortPinnedFirst;
|
||||||
|
@JsonKey(defaultValue: true)
|
||||||
|
final bool showHierarchicalTabs;
|
||||||
@DateTimeRangeConverter()
|
@DateTimeRangeConverter()
|
||||||
final DateTimeRange<DateTime>? dateRange;
|
final DateTimeRange<DateTime>? dateRange;
|
||||||
final TabQuickInterval? quickInterval;
|
final TabQuickInterval? quickInterval;
|
||||||
@@ -94,6 +96,7 @@ class TabViewFilterOptions with FastEquatable {
|
|||||||
required this.tabTypeFilter,
|
required this.tabTypeFilter,
|
||||||
required this.sortType,
|
required this.sortType,
|
||||||
required this.sortPinnedFirst,
|
required this.sortPinnedFirst,
|
||||||
|
required this.showHierarchicalTabs,
|
||||||
required this.dateRange,
|
required this.dateRange,
|
||||||
required this.quickInterval,
|
required this.quickInterval,
|
||||||
});
|
});
|
||||||
@@ -103,6 +106,7 @@ class TabViewFilterOptions with FastEquatable {
|
|||||||
tabTypeFilter: TabTypeFilter.all,
|
tabTypeFilter: TabTypeFilter.all,
|
||||||
sortType: TabSortType.manual,
|
sortType: TabSortType.manual,
|
||||||
sortPinnedFirst: true,
|
sortPinnedFirst: true,
|
||||||
|
showHierarchicalTabs: true,
|
||||||
dateRange: null,
|
dateRange: null,
|
||||||
quickInterval: null,
|
quickInterval: null,
|
||||||
);
|
);
|
||||||
@@ -132,6 +136,7 @@ class TabViewFilterOptions with FastEquatable {
|
|||||||
tabTypeFilter,
|
tabTypeFilter,
|
||||||
sortType,
|
sortType,
|
||||||
sortPinnedFirst,
|
sortPinnedFirst,
|
||||||
|
showHierarchicalTabs,
|
||||||
dateRange,
|
dateRange,
|
||||||
quickInterval,
|
quickInterval,
|
||||||
];
|
];
|
||||||
|
|||||||
+16
@@ -13,6 +13,8 @@ abstract class _$TabViewFilterOptionsCWProxy {
|
|||||||
|
|
||||||
TabViewFilterOptions sortPinnedFirst(bool sortPinnedFirst);
|
TabViewFilterOptions sortPinnedFirst(bool sortPinnedFirst);
|
||||||
|
|
||||||
|
TabViewFilterOptions showHierarchicalTabs(bool showHierarchicalTabs);
|
||||||
|
|
||||||
TabViewFilterOptions dateRange(DateTimeRange<DateTime>? dateRange);
|
TabViewFilterOptions dateRange(DateTimeRange<DateTime>? dateRange);
|
||||||
|
|
||||||
TabViewFilterOptions quickInterval(TabQuickInterval? quickInterval);
|
TabViewFilterOptions quickInterval(TabQuickInterval? quickInterval);
|
||||||
@@ -28,6 +30,7 @@ abstract class _$TabViewFilterOptionsCWProxy {
|
|||||||
TabTypeFilter tabTypeFilter,
|
TabTypeFilter tabTypeFilter,
|
||||||
TabSortType sortType,
|
TabSortType sortType,
|
||||||
bool sortPinnedFirst,
|
bool sortPinnedFirst,
|
||||||
|
bool showHierarchicalTabs,
|
||||||
DateTimeRange<DateTime>? dateRange,
|
DateTimeRange<DateTime>? dateRange,
|
||||||
TabQuickInterval? quickInterval,
|
TabQuickInterval? quickInterval,
|
||||||
});
|
});
|
||||||
@@ -53,6 +56,10 @@ class _$TabViewFilterOptionsCWProxyImpl
|
|||||||
TabViewFilterOptions sortPinnedFirst(bool sortPinnedFirst) =>
|
TabViewFilterOptions sortPinnedFirst(bool sortPinnedFirst) =>
|
||||||
call(sortPinnedFirst: sortPinnedFirst);
|
call(sortPinnedFirst: sortPinnedFirst);
|
||||||
|
|
||||||
|
@override
|
||||||
|
TabViewFilterOptions showHierarchicalTabs(bool showHierarchicalTabs) =>
|
||||||
|
call(showHierarchicalTabs: showHierarchicalTabs);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
TabViewFilterOptions dateRange(DateTimeRange<DateTime>? dateRange) =>
|
TabViewFilterOptions dateRange(DateTimeRange<DateTime>? dateRange) =>
|
||||||
call(dateRange: dateRange);
|
call(dateRange: dateRange);
|
||||||
@@ -73,6 +80,7 @@ class _$TabViewFilterOptionsCWProxyImpl
|
|||||||
Object? tabTypeFilter = const $CopyWithPlaceholder(),
|
Object? tabTypeFilter = const $CopyWithPlaceholder(),
|
||||||
Object? sortType = const $CopyWithPlaceholder(),
|
Object? sortType = const $CopyWithPlaceholder(),
|
||||||
Object? sortPinnedFirst = const $CopyWithPlaceholder(),
|
Object? sortPinnedFirst = const $CopyWithPlaceholder(),
|
||||||
|
Object? showHierarchicalTabs = const $CopyWithPlaceholder(),
|
||||||
Object? dateRange = const $CopyWithPlaceholder(),
|
Object? dateRange = const $CopyWithPlaceholder(),
|
||||||
Object? quickInterval = const $CopyWithPlaceholder(),
|
Object? quickInterval = const $CopyWithPlaceholder(),
|
||||||
}) {
|
}) {
|
||||||
@@ -92,6 +100,12 @@ class _$TabViewFilterOptionsCWProxyImpl
|
|||||||
? _value.sortPinnedFirst
|
? _value.sortPinnedFirst
|
||||||
// ignore: cast_nullable_to_non_nullable
|
// ignore: cast_nullable_to_non_nullable
|
||||||
: sortPinnedFirst as bool,
|
: sortPinnedFirst as bool,
|
||||||
|
showHierarchicalTabs:
|
||||||
|
showHierarchicalTabs == const $CopyWithPlaceholder() ||
|
||||||
|
showHierarchicalTabs == null
|
||||||
|
? _value.showHierarchicalTabs
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: showHierarchicalTabs as bool,
|
||||||
dateRange: dateRange == const $CopyWithPlaceholder()
|
dateRange: dateRange == const $CopyWithPlaceholder()
|
||||||
? _value.dateRange
|
? _value.dateRange
|
||||||
// ignore: cast_nullable_to_non_nullable
|
// ignore: cast_nullable_to_non_nullable
|
||||||
@@ -122,6 +136,7 @@ TabViewFilterOptions _$TabViewFilterOptionsFromJson(
|
|||||||
tabTypeFilter: $enumDecode(_$TabTypeFilterEnumMap, json['tabTypeFilter']),
|
tabTypeFilter: $enumDecode(_$TabTypeFilterEnumMap, json['tabTypeFilter']),
|
||||||
sortType: $enumDecode(_$TabSortTypeEnumMap, json['sortType']),
|
sortType: $enumDecode(_$TabSortTypeEnumMap, json['sortType']),
|
||||||
sortPinnedFirst: json['sortPinnedFirst'] as bool,
|
sortPinnedFirst: json['sortPinnedFirst'] as bool,
|
||||||
|
showHierarchicalTabs: json['showHierarchicalTabs'] as bool? ?? true,
|
||||||
dateRange: const DateTimeRangeConverter().fromJson(
|
dateRange: const DateTimeRangeConverter().fromJson(
|
||||||
json['dateRange'] as Map<String, dynamic>?,
|
json['dateRange'] as Map<String, dynamic>?,
|
||||||
),
|
),
|
||||||
@@ -137,6 +152,7 @@ Map<String, dynamic> _$TabViewFilterOptionsToJson(
|
|||||||
'tabTypeFilter': _$TabTypeFilterEnumMap[instance.tabTypeFilter]!,
|
'tabTypeFilter': _$TabTypeFilterEnumMap[instance.tabTypeFilter]!,
|
||||||
'sortType': _$TabSortTypeEnumMap[instance.sortType]!,
|
'sortType': _$TabSortTypeEnumMap[instance.sortType]!,
|
||||||
'sortPinnedFirst': instance.sortPinnedFirst,
|
'sortPinnedFirst': instance.sortPinnedFirst,
|
||||||
|
'showHierarchicalTabs': instance.showHierarchicalTabs,
|
||||||
'dateRange': const DateTimeRangeConverter().toJson(instance.dateRange),
|
'dateRange': const DateTimeRangeConverter().toJson(instance.dateRange),
|
||||||
'quickInterval': _$TabQuickIntervalEnumMap[instance.quickInterval],
|
'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/browser/presentation/controllers/tab_view_controllers.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.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/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/container_filter.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_entity.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';
|
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),
|
).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)
|
@Riverpod(keepAlive: true)
|
||||||
@@ -239,6 +246,10 @@ selectedContainerTabStatesWithContainer(Ref ref) {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
final tabBarDirection = ref.watch(
|
||||||
|
generalSettingsWithDefaultsProvider.select((s) => s.tabBarDirection),
|
||||||
|
);
|
||||||
|
|
||||||
items.sort((a, b) {
|
items.sort((a, b) {
|
||||||
final aPinned = pinnedTabIds?.contains(a.$1.id) ?? false;
|
final aPinned = pinnedTabIds?.contains(a.$1.id) ?? false;
|
||||||
final bPinned = pinnedTabIds?.contains(b.$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 aOrderKey = orderKeys[a.$1.id] ?? '';
|
||||||
final bOrderKey = orderKeys[b.$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);
|
return EquatableValue(items);
|
||||||
@@ -273,9 +287,23 @@ EquatableValue<List<TabStateWithContainer>> quickTabSwitcherTabStates(
|
|||||||
ref.watch(selectedContainerTabStatesWithContainerProvider).value,
|
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) {
|
return EquatableValue(switch (effectiveMode) {
|
||||||
QuickTabSwitcherMode.lastUsedTabs =>
|
QuickTabSwitcherMode.lastUsedTabs => () {
|
||||||
tabStates.where((state) => state.$1.id != selectedTabId).toList(),
|
final filtered = tabStates
|
||||||
|
.where((state) => state.$1.id != selectedTabId)
|
||||||
|
.toList();
|
||||||
|
return tabBarDirection == TabBarDirection.oldestFirst
|
||||||
|
? filtered.reversed.toList()
|
||||||
|
: filtered;
|
||||||
|
}(),
|
||||||
QuickTabSwitcherMode.containerTabs => tabStates,
|
QuickTabSwitcherMode.containerTabs => tabStates,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -587,11 +615,24 @@ EquatableValue<List<TabEntity>> seamlessFilteredTabEntities(
|
|||||||
? ref.watch(watchTabTimestampsProvider.select((value) => value.value))
|
? ref.watch(watchTabTimestampsProvider.select((value) => value.value))
|
||||||
: null;
|
: 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 (tabSearchResults == null) {
|
||||||
if (filterOptions.hasActiveFilter || pinnedTabIds.isNotEmpty) {
|
if (filterOptions.hasActiveFilter || pinnedTabIds.isNotEmpty) {
|
||||||
return EquatableValue(
|
return EquatableValue(
|
||||||
_applyTabFiltersAndSort(
|
_applyTabFiltersAndSort(
|
||||||
availableTabs.value,
|
applyDirection(availableTabs.value),
|
||||||
filterOptions,
|
filterOptions,
|
||||||
tabStates,
|
tabStates,
|
||||||
pinnedTabIds,
|
pinnedTabIds,
|
||||||
@@ -600,7 +641,7 @@ EquatableValue<List<TabEntity>> seamlessFilteredTabEntities(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return availableTabs;
|
return EquatableValue(applyDirection(availableTabs.value));
|
||||||
}
|
}
|
||||||
|
|
||||||
final searchFiltered = tabSearchResults
|
final searchFiltered = tabSearchResults
|
||||||
@@ -624,6 +665,7 @@ EquatableValue<List<TabEntity>> seamlessFilteredTabEntities(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return EquatableValue(searchFiltered);
|
return EquatableValue(searchFiltered);
|
||||||
|
// Search results retain the search-relevance ordering — no direction flip.
|
||||||
}
|
}
|
||||||
|
|
||||||
@Riverpod()
|
@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()
|
@Riverpod()
|
||||||
class AppLinksModeNotifier extends _$AppLinksModeNotifier {
|
class AppLinksModeNotifier extends _$AppLinksModeNotifier {
|
||||||
final _service = GeckoEngineSettingsService();
|
final _service = GeckoEngineSettingsService();
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ final class CanManualTabReorderProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$canManualTabReorderHash() =>
|
String _$canManualTabReorderHash() =>
|
||||||
r'ba5d961933464b6e005d7945802908a9a4ae034b';
|
r'598bc67a750f45893ba555b9b7866499b1808896';
|
||||||
|
|
||||||
@ProviderFor(SelectedBangTrigger)
|
@ProviderFor(SelectedBangTrigger)
|
||||||
final selectedBangTriggerProvider = SelectedBangTriggerFamily._();
|
final selectedBangTriggerProvider = SelectedBangTriggerFamily._();
|
||||||
@@ -529,7 +529,7 @@ final class SelectedContainerTabStatesWithContainerProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$selectedContainerTabStatesWithContainerHash() =>
|
String _$selectedContainerTabStatesWithContainerHash() =>
|
||||||
r'187e408857ddc5fe08f8e5344beb510c3c39abe1';
|
r'6b630cdfc589af6961d4c96c90d09bd107560f15';
|
||||||
|
|
||||||
@ProviderFor(quickTabSwitcherTabStates)
|
@ProviderFor(quickTabSwitcherTabStates)
|
||||||
final quickTabSwitcherTabStatesProvider = QuickTabSwitcherTabStatesFamily._();
|
final quickTabSwitcherTabStatesProvider = QuickTabSwitcherTabStatesFamily._();
|
||||||
@@ -601,7 +601,7 @@ final class QuickTabSwitcherTabStatesProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$quickTabSwitcherTabStatesHash() =>
|
String _$quickTabSwitcherTabStatesHash() =>
|
||||||
r'd83ea34d366238681baaad0b36699ee0742edc3d';
|
r'32e928b4596ef2388f8c0e4cfdcf8f3563a1e24a';
|
||||||
|
|
||||||
final class QuickTabSwitcherTabStatesFamily extends $Family
|
final class QuickTabSwitcherTabStatesFamily extends $Family
|
||||||
with
|
with
|
||||||
@@ -962,7 +962,7 @@ final class SeamlessFilteredTabEntitiesProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$seamlessFilteredTabEntitiesHash() =>
|
String _$seamlessFilteredTabEntitiesHash() =>
|
||||||
r'bc6833f975e9af3b2117268d7644dc56131c85a3';
|
r'79abca3ad2753af4482c0e3bcfa317a6f336ca17';
|
||||||
|
|
||||||
final class SeamlessFilteredTabEntitiesFamily extends $Family
|
final class SeamlessFilteredTabEntitiesFamily extends $Family
|
||||||
with
|
with
|
||||||
@@ -1095,6 +1095,139 @@ final class FilteredTabPreviewsFamily extends $Family
|
|||||||
String toString() => r'filteredTabPreviewsProvider';
|
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)
|
@ProviderFor(AppLinksModeNotifier)
|
||||||
final appLinksModeProvider = AppLinksModeNotifierProvider._();
|
final appLinksModeProvider = AppLinksModeNotifierProvider._();
|
||||||
|
|
||||||
|
|||||||
+18
-8
@@ -50,20 +50,30 @@ class ContextualToolbarConfigRepository {
|
|||||||
return _dao.assignFallback(buttonId, fallbackId);
|
return _dao.assignFallback(buttonId, fallbackId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> generateLeadingOrderKey() {
|
Future<String> generateLeadingOrderKey({required bool isVisible}) {
|
||||||
return _dao.generateLeadingOrderKey().getSingle();
|
return _dao.generateLeadingOrderKey(isVisible: isVisible).getSingle();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> generateTrailingOrderKey() {
|
Future<String> generateTrailingOrderKey({required bool isVisible}) {
|
||||||
return _dao.generateTrailingOrderKey().getSingle();
|
return _dao.generateTrailingOrderKey(isVisible: isVisible).getSingle();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> generateOrderKeyAfterButtonId(String buttonId) {
|
Future<String?> generateOrderKeyAfterButtonId(
|
||||||
return _dao.generateOrderKeyAfterButtonId(buttonId).getSingleOrNull();
|
String buttonId, {
|
||||||
|
required bool isVisible,
|
||||||
|
}) {
|
||||||
|
return _dao
|
||||||
|
.generateOrderKeyAfterButtonId(buttonId, isVisible: isVisible)
|
||||||
|
.getSingleOrNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> generateOrderKeyBeforeButtonId(String buttonId) {
|
Future<String> generateOrderKeyBeforeButtonId(
|
||||||
return _dao.generateOrderKeyBeforeButtonId(buttonId).getSingle();
|
String buttonId, {
|
||||||
|
required bool isVisible,
|
||||||
|
}) {
|
||||||
|
return _dao
|
||||||
|
.generateOrderKeyBeforeButtonId(buttonId, isVisible: isVisible)
|
||||||
|
.getSingle();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> seedMissingDefaults() {
|
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/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/readerview/presentation/controllers/readerable.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.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/settings/presentation/controllers/save_settings.dart';
|
||||||
import 'package:weblibre/features/user/data/models/engine_settings.dart';
|
import 'package:weblibre/features/user/data/models/engine_settings.dart';
|
||||||
import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.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'),
|
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(
|
child: IconButton(
|
||||||
onPressed: scope.isPreview
|
onPressed: scope.isPreview
|
||||||
|
|||||||
+23
@@ -80,6 +80,10 @@ class TabViewFilterController extends _$TabViewFilterController {
|
|||||||
state = state.copyWith(sortPinnedFirst: value);
|
state = state.copyWith(sortPinnedFirst: value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setShowHierarchicalTabs(bool value) {
|
||||||
|
state = state.copyWith(showHierarchicalTabs: value);
|
||||||
|
}
|
||||||
|
|
||||||
void setDateRange(DateTimeRange<DateTime>? range) {
|
void setDateRange(DateTimeRange<DateTime>? range) {
|
||||||
// ignore: avoid_redundant_argument_values
|
// ignore: avoid_redundant_argument_values
|
||||||
state = state.copyWith(dateRange: range, quickInterval: null);
|
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()
|
@Riverpod()
|
||||||
class TabsReorderableController extends _$TabsReorderableController {
|
class TabsReorderableController extends _$TabsReorderableController {
|
||||||
void toggle() {
|
void toggle() {
|
||||||
|
|||||||
+75
-1
@@ -97,7 +97,7 @@ final class TabViewFilterControllerProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$tabViewFilterControllerHash() =>
|
String _$tabViewFilterControllerHash() =>
|
||||||
r'95e8a03d60ebe05e0c5df38f810f951a4fe2ed78';
|
r'6f5d42761765c0135bf40953c574c17d141ffab2';
|
||||||
|
|
||||||
@JsonPersist()
|
@JsonPersist()
|
||||||
abstract class _$TabViewFilterControllerBase
|
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)
|
@ProviderFor(TabsReorderableController)
|
||||||
final tabsReorderableControllerProvider = TabsReorderableControllerProvider._();
|
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'),
|
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(
|
child: _buildNavIcon(
|
||||||
icon: MdiIcons.tabMinus,
|
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/domain/providers/tab_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/domain/providers.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/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/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_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_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_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_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/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/container_filter.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_entity.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/providers/selected_container.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/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.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart';
|
||||||
import 'package:weblibre/features/user/domain/repositories/general_settings.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 {
|
class _TabDraggable extends HookConsumerWidget {
|
||||||
final TabEntity entity;
|
final String tabId;
|
||||||
|
final String? sourceSearchQuery;
|
||||||
final String? suggestedContainerId;
|
final String? suggestedContainerId;
|
||||||
final VoidCallback onClose;
|
final VoidCallback onClose;
|
||||||
|
final Widget? groupToggle;
|
||||||
|
final int depth;
|
||||||
|
|
||||||
const _TabDraggable({
|
const _TabDraggable({
|
||||||
required this.entity,
|
required this.tabId,
|
||||||
required this.onClose,
|
required this.onClose,
|
||||||
|
this.sourceSearchQuery,
|
||||||
this.suggestedContainerId,
|
this.suggestedContainerId,
|
||||||
|
this.groupToggle,
|
||||||
|
this.depth = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -73,7 +110,7 @@ class _TabDraggable extends HookConsumerWidget {
|
|||||||
null => null,
|
null => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (dragTabId == entity.tabId) ? value : null;
|
return (dragTabId == tabId) ? value : null;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -81,8 +118,8 @@ class _TabDraggable extends HookConsumerWidget {
|
|||||||
final tab = useMemoized(() {
|
final tab = useMemoized(() {
|
||||||
return (suggestedContainerId != null)
|
return (suggestedContainerId != null)
|
||||||
? SuggestedSingleGridTabPreview(
|
? SuggestedSingleGridTabPreview(
|
||||||
key: ValueKey(entity.tabId),
|
key: ValueKey(tabId),
|
||||||
tabId: entity.tabId,
|
tabId: tabId,
|
||||||
activeTabId: activeTab,
|
activeTabId: activeTab,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final containerData = await ref
|
final containerData = await ref
|
||||||
@@ -92,24 +129,20 @@ class _TabDraggable extends HookConsumerWidget {
|
|||||||
if (containerData != null) {
|
if (containerData != null) {
|
||||||
await ref
|
await ref
|
||||||
.read(tabDataRepositoryProvider.notifier)
|
.read(tabDataRepositoryProvider.notifier)
|
||||||
.assignContainer(entity.tabId, containerData);
|
.assignContainer(tabId, containerData);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: SingleGridTabPreview(
|
: SingleGridTabPreview(
|
||||||
key: ValueKey(entity.tabId),
|
key: ValueKey(tabId),
|
||||||
tabId: entity.tabId,
|
tabId: tabId,
|
||||||
activeTabId: activeTab,
|
activeTabId: activeTab,
|
||||||
onClose: onClose,
|
onClose: onClose,
|
||||||
sourceSearchQuery: switch (entity) {
|
sourceSearchQuery: sourceSearchQuery,
|
||||||
DefaultTabEntity _ => null,
|
groupToggle: groupToggle,
|
||||||
final SearchResultTabEntity entity => entity.searchQuery,
|
depth: depth,
|
||||||
TabTreeEntity _ => throw UnimplementedError(
|
|
||||||
'TabTreeEntity not implemented in tab grid view',
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}, [entity.tabId, activeTab, suggestedContainerId]);
|
}, [tabId, activeTab, suggestedContainerId, groupToggle, depth]);
|
||||||
|
|
||||||
return switch (dragData) {
|
return switch (dragData) {
|
||||||
ContainerDropData() => Opacity(
|
ContainerDropData() => Opacity(
|
||||||
@@ -144,19 +177,71 @@ class _TabGridView extends HookConsumerWidget {
|
|||||||
final screenWidth = MediaQuery.of(context).size.width;
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
final disableAnimations = MediaQuery.disableAnimationsOf(context);
|
final disableAnimations = MediaQuery.disableAnimationsOf(context);
|
||||||
final canManualReorder = ref.watch(canManualTabReorderProvider);
|
final canManualReorder = ref.watch(canManualTabReorderProvider);
|
||||||
final reorderEnabled = tabsReorderable && canManualReorder;
|
|
||||||
|
|
||||||
final containerId = ref.watch(selectedContainerProvider);
|
final containerId = ref.watch(selectedContainerProvider);
|
||||||
|
final reorderEnabled = tabsReorderable && canManualReorder;
|
||||||
final filteredTabEntities = ref.watch(
|
final filterOptions = ref.watch(tabViewFilterControllerProvider);
|
||||||
seamlessFilteredTabEntitiesProvider(
|
final showHierarchicalTabs = filterOptions.showHierarchicalTabs;
|
||||||
searchPartition: TabSearchPartition.preview,
|
final tabListDirection = ref.watch(
|
||||||
// ignore: document_ignores using fast equatable
|
generalSettingsWithDefaultsProvider.select((s) => s.tabListDirection),
|
||||||
// ignore: provider_parameters
|
|
||||||
containerFilter: ContainerFilterById(containerId: containerId),
|
|
||||||
groupTrees: false,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
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(
|
final tabSuggestionsEnabled = ref.watch(
|
||||||
persistedBoolProvider(PersistedBoolKey.tabSuggestions),
|
persistedBoolProvider(PersistedBoolKey.tabSuggestions),
|
||||||
@@ -167,12 +252,10 @@ class _TabGridView extends HookConsumerWidget {
|
|||||||
: EquatableValue(<TabEntity>[]);
|
: EquatableValue(<TabEntity>[]);
|
||||||
|
|
||||||
final itemCount =
|
final itemCount =
|
||||||
filteredTabEntities.value.length +
|
primaryRows.length +
|
||||||
//Limit to 3 sugegstions for now
|
//Limit to 3 sugegstions for now
|
||||||
math.min<int>(suggestedTabEntities.value.length, 3);
|
math.min<int>(suggestedTabEntities.value.length, 3);
|
||||||
final displayItemCount = reorderEnabled
|
final displayItemCount = reorderEnabled ? primaryRows.length : itemCount;
|
||||||
? filteredTabEntities.value.length
|
|
||||||
: itemCount;
|
|
||||||
|
|
||||||
final activeTab = ref.watch(selectedTabProvider);
|
final activeTab = ref.watch(selectedTabProvider);
|
||||||
|
|
||||||
@@ -205,9 +288,7 @@ class _TabGridView extends HookConsumerWidget {
|
|||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (scrollController.hasClients && activeTab != null) {
|
if (scrollController.hasClients && activeTab != null) {
|
||||||
final index = filteredTabEntities.value.indexWhere(
|
final index = primaryRows.indexWhere((row) => row.tabId == activeTab);
|
||||||
(entity) => entity.tabId == activeTab,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
final row = index ~/ crossAxisCount;
|
final row = index ~/ crossAxisCount;
|
||||||
@@ -273,7 +354,7 @@ class _TabGridView extends HookConsumerWidget {
|
|||||||
return widget;
|
return widget;
|
||||||
},
|
},
|
||||||
suggestedContainerId: containerId,
|
suggestedContainerId: containerId,
|
||||||
filteredTabEntities: filteredTabEntities,
|
primaryRows: primaryRows,
|
||||||
suggestedTabEntities: suggestedTabEntities,
|
suggestedTabEntities: suggestedTabEntities,
|
||||||
onClose: onClose,
|
onClose: onClose,
|
||||||
)
|
)
|
||||||
@@ -294,57 +375,30 @@ class _TabGridView extends HookConsumerWidget {
|
|||||||
final oldIndex = positions.first.oldIndex;
|
final oldIndex = positions.first.oldIndex;
|
||||||
final newIndex = positions.first.newIndex;
|
final newIndex = positions.first.newIndex;
|
||||||
|
|
||||||
final containerRepository = ref.read(
|
|
||||||
containerRepositoryProvider.notifier,
|
|
||||||
);
|
|
||||||
|
|
||||||
//Suggestions are at the end and not reorderable, so skip
|
//Suggestions are at the end and not reorderable, so skip
|
||||||
if (oldIndex >= filteredTabEntities.value.length) {
|
if (oldIndex >= primaryRows.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final tabId = filteredTabEntities.value[oldIndex].tabId;
|
final result = buildTabViewReorderResult(
|
||||||
final containerId = await ref
|
visibleItems: primaryRows,
|
||||||
.read(tabDataRepositoryProvider.notifier)
|
treeRows: treeRows,
|
||||||
.getTabContainerId(tabId);
|
collapsedGroups: collapsedGroups,
|
||||||
|
oldIndex: oldIndex,
|
||||||
var targetIndex = newIndex;
|
newIndex: newIndex,
|
||||||
if (targetIndex > oldIndex) {
|
tabListDirection: tabListDirection,
|
||||||
targetIndex -= 1;
|
hierarchical: showHierarchicalTabs && !hasActiveSearch,
|
||||||
}
|
|
||||||
|
|
||||||
targetIndex = targetIndex.clamp(
|
|
||||||
0,
|
|
||||||
filteredTabEntities.value.length - 1,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final String key;
|
if (result == null) return;
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await ref
|
await ref
|
||||||
.read(tabDataRepositoryProvider.notifier)
|
.read(tabDataRepositoryProvider.notifier)
|
||||||
.assignOrderKey(tabId, key);
|
.reorderTabs(
|
||||||
|
movingTabIds: result.movingTabIds,
|
||||||
|
previousTabId: result.previousTabId,
|
||||||
|
nextTabId: result.nextTabId,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
childBuilder: (reorderableItemBuilder) {
|
childBuilder: (reorderableItemBuilder) {
|
||||||
return _TabGrid(
|
return _TabGrid(
|
||||||
@@ -371,7 +425,7 @@ class _TabGridView extends HookConsumerWidget {
|
|||||||
return reorderableItemBuilder(wrapped, index);
|
return reorderableItemBuilder(wrapped, index);
|
||||||
},
|
},
|
||||||
suggestedContainerId: containerId,
|
suggestedContainerId: containerId,
|
||||||
filteredTabEntities: filteredTabEntities,
|
primaryRows: primaryRows,
|
||||||
suggestedTabEntities: suggestedTabEntities,
|
suggestedTabEntities: suggestedTabEntities,
|
||||||
onClose: onClose,
|
onClose: onClose,
|
||||||
);
|
);
|
||||||
@@ -391,7 +445,7 @@ class _TabGrid extends StatelessWidget {
|
|||||||
required this.itemCount,
|
required this.itemCount,
|
||||||
required this.itemBuilder,
|
required this.itemBuilder,
|
||||||
required this.suggestedContainerId,
|
required this.suggestedContainerId,
|
||||||
required this.filteredTabEntities,
|
required this.primaryRows,
|
||||||
required this.suggestedTabEntities,
|
required this.suggestedTabEntities,
|
||||||
required this.onClose,
|
required this.onClose,
|
||||||
});
|
});
|
||||||
@@ -400,7 +454,7 @@ class _TabGrid extends StatelessWidget {
|
|||||||
final int itemCount;
|
final int itemCount;
|
||||||
final ScrollController? scrollController;
|
final ScrollController? scrollController;
|
||||||
final String? suggestedContainerId;
|
final String? suggestedContainerId;
|
||||||
final EquatableValue<List<TabEntity>> filteredTabEntities;
|
final List<TabViewItem> primaryRows;
|
||||||
final EquatableValue<List<TabEntity>> suggestedTabEntities;
|
final EquatableValue<List<TabEntity>> suggestedTabEntities;
|
||||||
final Widget Function(Widget, int)? itemBuilder;
|
final Widget Function(Widget, int)? itemBuilder;
|
||||||
final VoidCallback onClose;
|
final VoidCallback onClose;
|
||||||
@@ -419,35 +473,38 @@ class _TabGrid extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
itemCount: itemCount,
|
itemCount: itemCount,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final TabEntity entity;
|
if (index < primaryRows.length) {
|
||||||
final String? suggestedId;
|
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) {
|
if (itemBuilder == null) {
|
||||||
entity = filteredTabEntities.value[index];
|
return TabDropTarget(targetTabId: row.tabId, child: tab);
|
||||||
suggestedId = null;
|
}
|
||||||
} else {
|
return itemBuilder!(tab, index);
|
||||||
final suggestedIndex = index - filteredTabEntities.value.length;
|
|
||||||
entity = suggestedTabEntities.value[suggestedIndex];
|
|
||||||
suggestedId = suggestedContainerId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final suggestedIndex = index - primaryRows.length;
|
||||||
|
final entity = suggestedTabEntities.value[suggestedIndex];
|
||||||
|
|
||||||
final tab = CustomDraggable(
|
final tab = CustomDraggable(
|
||||||
key: Key(
|
key: Key('suggested_${entity.tabId}'),
|
||||||
suggestedId != null ? 'suggested_${entity.tabId}' : entity.tabId,
|
|
||||||
),
|
|
||||||
data: suggestedId != null ? null : TabDragData(entity.tabId),
|
|
||||||
child: _TabDraggable(
|
child: _TabDraggable(
|
||||||
entity: entity,
|
tabId: entity.tabId,
|
||||||
onClose: onClose,
|
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;
|
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/domain/providers/tab_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/domain/providers.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/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/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_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_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_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_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/container_filter.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_entity.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/providers/selected_container.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/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.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/sync/domain/repositories/sync.dart';
|
||||||
import 'package:weblibre/features/user/domain/repositories/general_settings.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 {
|
class _TabDraggable extends HookConsumerWidget {
|
||||||
final TabEntity entity;
|
final String tabId;
|
||||||
|
final String? sourceSearchQuery;
|
||||||
final String? suggestedContainerId;
|
final String? suggestedContainerId;
|
||||||
final VoidCallback onClose;
|
final VoidCallback onClose;
|
||||||
final double height;
|
final double height;
|
||||||
|
final Widget? groupToggle;
|
||||||
|
final int depth;
|
||||||
|
|
||||||
const _TabDraggable({
|
const _TabDraggable({
|
||||||
required this.entity,
|
required this.tabId,
|
||||||
required this.onClose,
|
required this.onClose,
|
||||||
required this.height,
|
required this.height,
|
||||||
|
this.sourceSearchQuery,
|
||||||
this.suggestedContainerId,
|
this.suggestedContainerId,
|
||||||
|
this.groupToggle,
|
||||||
|
this.depth = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -75,7 +111,7 @@ class _TabDraggable extends HookConsumerWidget {
|
|||||||
null => null,
|
null => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (dragTabId == entity.tabId) ? value : null;
|
return (dragTabId == tabId) ? value : null;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -83,8 +119,8 @@ class _TabDraggable extends HookConsumerWidget {
|
|||||||
final tab = useMemoized(() {
|
final tab = useMemoized(() {
|
||||||
return (suggestedContainerId != null)
|
return (suggestedContainerId != null)
|
||||||
? SuggestedSingleListTabPreview(
|
? SuggestedSingleListTabPreview(
|
||||||
key: ValueKey(entity.tabId),
|
key: ValueKey(tabId),
|
||||||
tabId: entity.tabId,
|
tabId: tabId,
|
||||||
activeTabId: activeTab,
|
activeTabId: activeTab,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final containerData = await ref
|
final containerData = await ref
|
||||||
@@ -94,24 +130,20 @@ class _TabDraggable extends HookConsumerWidget {
|
|||||||
if (containerData != null) {
|
if (containerData != null) {
|
||||||
await ref
|
await ref
|
||||||
.read(tabDataRepositoryProvider.notifier)
|
.read(tabDataRepositoryProvider.notifier)
|
||||||
.assignContainer(entity.tabId, containerData);
|
.assignContainer(tabId, containerData);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: SingleListTabPreview(
|
: SingleListTabPreview(
|
||||||
key: ValueKey(entity.tabId),
|
key: ValueKey(tabId),
|
||||||
tabId: entity.tabId,
|
tabId: tabId,
|
||||||
activeTabId: activeTab,
|
activeTabId: activeTab,
|
||||||
onClose: onClose,
|
onClose: onClose,
|
||||||
sourceSearchQuery: switch (entity) {
|
sourceSearchQuery: sourceSearchQuery,
|
||||||
DefaultTabEntity _ => null,
|
groupToggle: groupToggle,
|
||||||
final SearchResultTabEntity entity => entity.searchQuery,
|
depth: depth,
|
||||||
TabTreeEntity _ => throw UnimplementedError(
|
|
||||||
'TabTreeEntity not implemented in tab list view',
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}, [entity.tabId, activeTab, suggestedContainerId]);
|
}, [tabId, activeTab, suggestedContainerId, groupToggle, depth]);
|
||||||
|
|
||||||
return switch (dragData) {
|
return switch (dragData) {
|
||||||
ContainerDropData() => Opacity(
|
ContainerDropData() => Opacity(
|
||||||
@@ -208,16 +240,69 @@ class _TabListView extends HookConsumerWidget {
|
|||||||
final containerId = ref.watch(selectedContainerProvider);
|
final containerId = ref.watch(selectedContainerProvider);
|
||||||
final canManualReorder = ref.watch(canManualTabReorderProvider);
|
final canManualReorder = ref.watch(canManualTabReorderProvider);
|
||||||
final reorderEnabled = tabsReorderable && canManualReorder;
|
final reorderEnabled = tabsReorderable && canManualReorder;
|
||||||
|
final filterOptions = ref.watch(tabViewFilterControllerProvider);
|
||||||
final filteredTabEntities = ref.watch(
|
final showHierarchicalTabs = filterOptions.showHierarchicalTabs;
|
||||||
seamlessFilteredTabEntitiesProvider(
|
final tabListDirection = ref.watch(
|
||||||
searchPartition: TabSearchPartition.preview,
|
generalSettingsWithDefaultsProvider.select((s) => s.tabListDirection),
|
||||||
// ignore: document_ignores using fast equatable
|
|
||||||
// ignore: provider_parameters
|
|
||||||
containerFilter: ContainerFilterById(containerId: containerId),
|
|
||||||
groupTrees: false,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
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(
|
final tabSuggestionsEnabled = ref.watch(
|
||||||
persistedBoolProvider(PersistedBoolKey.tabSuggestions),
|
persistedBoolProvider(PersistedBoolKey.tabSuggestions),
|
||||||
@@ -228,12 +313,10 @@ class _TabListView extends HookConsumerWidget {
|
|||||||
: EquatableValue(<TabEntity>[]);
|
: EquatableValue(<TabEntity>[]);
|
||||||
|
|
||||||
final itemCount =
|
final itemCount =
|
||||||
filteredTabEntities.value.length +
|
primaryRows.length +
|
||||||
//Limit to 3 sugegstions for now
|
//Limit to 3 sugegstions for now
|
||||||
math.min<int>(suggestedTabEntities.value.length, 3);
|
math.min<int>(suggestedTabEntities.value.length, 3);
|
||||||
final displayItemCount = reorderEnabled
|
final displayItemCount = reorderEnabled ? primaryRows.length : itemCount;
|
||||||
? filteredTabEntities.value.length
|
|
||||||
: itemCount;
|
|
||||||
|
|
||||||
final activeTab = ref.watch(selectedTabProvider);
|
final activeTab = ref.watch(selectedTabProvider);
|
||||||
|
|
||||||
@@ -244,9 +327,7 @@ class _TabListView extends HookConsumerWidget {
|
|||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (scrollController.hasClients && activeTab != null) {
|
if (scrollController.hasClients && activeTab != null) {
|
||||||
final index = filteredTabEntities.value.indexWhere(
|
final index = primaryRows.indexWhere((row) => row.tabId == activeTab);
|
||||||
(entity) => entity.tabId == activeTab,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
final viewportStart = scrollController.offset;
|
final viewportStart = scrollController.offset;
|
||||||
@@ -302,50 +383,50 @@ class _TabListView extends HookConsumerWidget {
|
|||||||
itemCount: displayItemCount,
|
itemCount: displayItemCount,
|
||||||
itemExtent: _itemHeight,
|
itemExtent: _itemHeight,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final TabEntity entity;
|
if (index < primaryRows.length) {
|
||||||
final String? suggestedId;
|
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) {
|
return TabDropTarget(
|
||||||
entity = filteredTabEntities.value[index];
|
targetTabId: row.tabId,
|
||||||
suggestedId = null;
|
child: TabContextMenuDraggable(
|
||||||
} else {
|
tabId: row.tabId,
|
||||||
final suggestedIndex =
|
data: tab.data! as TabDragData,
|
||||||
index - filteredTabEntities.value.length;
|
feedbackSize: Size(
|
||||||
entity = suggestedTabEntities.value[suggestedIndex];
|
MediaQuery.of(context).size.width,
|
||||||
suggestedId = containerId;
|
_itemHeight,
|
||||||
|
),
|
||||||
|
child: tab.child,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final suggestedIndex = index - primaryRows.length;
|
||||||
|
final entity = suggestedTabEntities.value[suggestedIndex];
|
||||||
final tab = CustomDraggable(
|
final tab = CustomDraggable(
|
||||||
key: Key(
|
key: Key('suggested_${entity.tabId}'),
|
||||||
suggestedId != null
|
|
||||||
? 'suggested_${entity.tabId}'
|
|
||||||
: entity.tabId,
|
|
||||||
),
|
|
||||||
data: suggestedId != null
|
|
||||||
? null
|
|
||||||
: TabDragData(entity.tabId),
|
|
||||||
child: _TabDraggable(
|
child: _TabDraggable(
|
||||||
entity: entity,
|
tabId: entity.tabId,
|
||||||
onClose: onClose,
|
onClose: onClose,
|
||||||
suggestedContainerId: suggestedId,
|
suggestedContainerId: containerId,
|
||||||
height: _itemHeight,
|
height: _itemHeight,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return TabDropTarget(
|
return TabDropTarget(
|
||||||
targetTabId: entity.tabId,
|
targetTabId: entity.tabId,
|
||||||
enabled: suggestedId == null,
|
enabled: false,
|
||||||
child: tab.data is TabDragData
|
child: tab.child,
|
||||||
? 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,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -357,84 +438,59 @@ class _TabListView extends HookConsumerWidget {
|
|||||||
ref.read(willAcceptDropProvider.notifier).clear();
|
ref.read(willAcceptDropProvider.notifier).clear();
|
||||||
},
|
},
|
||||||
onReorder: (oldIndex, newIndex) async {
|
onReorder: (oldIndex, newIndex) async {
|
||||||
final containerRepository = ref.read(
|
|
||||||
containerRepositoryProvider.notifier,
|
|
||||||
);
|
|
||||||
|
|
||||||
//Suggestions are at the end and not reorderable, so skip
|
//Suggestions are at the end and not reorderable, so skip
|
||||||
if (oldIndex >= filteredTabEntities.value.length) {
|
if (oldIndex >= primaryRows.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final tabId = filteredTabEntities.value[oldIndex].tabId;
|
final result = buildTabViewReorderResult(
|
||||||
final containerId = await ref
|
visibleItems: primaryRows,
|
||||||
.read(tabDataRepositoryProvider.notifier)
|
treeRows: treeRows,
|
||||||
.getTabContainerId(tabId);
|
collapsedGroups: collapsedGroups,
|
||||||
|
oldIndex: oldIndex,
|
||||||
var targetIndex = newIndex;
|
newIndex: newIndex,
|
||||||
if (targetIndex > oldIndex) {
|
tabListDirection: tabListDirection,
|
||||||
targetIndex -= 1;
|
hierarchical: showHierarchicalTabs && !hasActiveSearch,
|
||||||
}
|
|
||||||
|
|
||||||
targetIndex = targetIndex.clamp(
|
|
||||||
0,
|
|
||||||
filteredTabEntities.value.length - 1,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final String key;
|
if (result == null) return;
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await ref
|
await ref
|
||||||
.read(tabDataRepositoryProvider.notifier)
|
.read(tabDataRepositoryProvider.notifier)
|
||||||
.assignOrderKey(tabId, key);
|
.reorderTabs(
|
||||||
|
movingTabIds: result.movingTabIds,
|
||||||
|
previousTabId: result.previousTabId,
|
||||||
|
nextTabId: result.nextTabId,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (index < filteredTabEntities.value.length) {
|
if (index < primaryRows.length) {
|
||||||
final entity = filteredTabEntities.value[index];
|
final row = primaryRows[index];
|
||||||
return CustomDraggable(
|
return CustomDraggable(
|
||||||
key: Key(entity.tabId),
|
key: Key(row.tabId),
|
||||||
data: TabDragData(entity.tabId),
|
data: TabDragData(row.tabId),
|
||||||
child: TabContextMenuDraggable(
|
child: TabContextMenuDraggable(
|
||||||
tabId: entity.tabId,
|
tabId: row.tabId,
|
||||||
feedbackSize: Size.zero,
|
feedbackSize: Size.zero,
|
||||||
externalDrag: true,
|
externalDrag: true,
|
||||||
child: _TabDraggable(
|
child: _TabDraggable(
|
||||||
entity: entity,
|
tabId: row.tabId,
|
||||||
onClose: onClose,
|
onClose: onClose,
|
||||||
|
sourceSearchQuery: row.sourceSearchQuery,
|
||||||
height: _itemHeight,
|
height: _itemHeight,
|
||||||
|
groupToggle: _listGroupToggleFor(row),
|
||||||
|
depth: _depthFor(row),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
final suggestedIndex =
|
final suggestedIndex = index - primaryRows.length;
|
||||||
index - filteredTabEntities.value.length;
|
|
||||||
final entity = suggestedTabEntities.value[suggestedIndex];
|
final entity = suggestedTabEntities.value[suggestedIndex];
|
||||||
|
|
||||||
return CustomDraggable(
|
return CustomDraggable(
|
||||||
key: Key('suggested_${entity.tabId}'),
|
key: Key('suggested_${entity.tabId}'),
|
||||||
child: _TabDraggable(
|
child: _TabDraggable(
|
||||||
entity: entity,
|
tabId: entity.tabId,
|
||||||
onClose: onClose,
|
onClose: onClose,
|
||||||
suggestedContainerId: containerId,
|
suggestedContainerId: containerId,
|
||||||
height: _itemHeight,
|
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/entities/states/tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.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/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/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/domain/entities/find_in_page_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
|
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
|
||||||
@@ -127,11 +128,22 @@ class GridTabPreview extends HookConsumerWidget {
|
|||||||
final VoidCallback? onDoubleTap;
|
final VoidCallback? onDoubleTap;
|
||||||
final VoidCallback? onDelete;
|
final VoidCallback? onDelete;
|
||||||
final void Function(String host)? onDeleteAll;
|
final void Function(String host)? onDeleteAll;
|
||||||
|
final VoidCallback? onCloseSubtree;
|
||||||
|
|
||||||
final bool showPinBadge;
|
final bool showPinBadge;
|
||||||
|
|
||||||
final Widget? trailingChild;
|
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({
|
const GridTabPreview({
|
||||||
required this.tabId,
|
required this.tabId,
|
||||||
required this.isActive,
|
required this.isActive,
|
||||||
@@ -139,8 +151,11 @@ class GridTabPreview extends HookConsumerWidget {
|
|||||||
this.onDoubleTap,
|
this.onDoubleTap,
|
||||||
this.onDelete,
|
this.onDelete,
|
||||||
this.onDeleteAll,
|
this.onDeleteAll,
|
||||||
|
this.onCloseSubtree,
|
||||||
this.showPinBadge = false,
|
this.showPinBadge = false,
|
||||||
this.trailingChild,
|
this.trailingChild,
|
||||||
|
this.groupToggle,
|
||||||
|
this.depth = 0,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -220,7 +235,9 @@ class GridTabPreview extends HookConsumerWidget {
|
|||||||
else
|
else
|
||||||
Center(child: TabIcon(tabState: tabState, iconSize: 48)),
|
Center(child: TabIcon(tabState: tabState, iconSize: 48)),
|
||||||
// Close button overlay
|
// Close button overlay
|
||||||
if (onDelete != null || onDeleteAll != null)
|
if (onDelete != null ||
|
||||||
|
onDeleteAll != null ||
|
||||||
|
onCloseSubtree != null)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 6.0,
|
top: 6.0,
|
||||||
right: 6.0,
|
right: 6.0,
|
||||||
@@ -237,6 +254,12 @@ class GridTabPreview extends HookConsumerWidget {
|
|||||||
leadingIcon: const Icon(Icons.language),
|
leadingIcon: const Icon(Icons.language),
|
||||||
child: const Text('Close from Same Host'),
|
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(
|
child: SizedBox(
|
||||||
width: 28,
|
width: 28,
|
||||||
@@ -252,7 +275,8 @@ class GridTabPreview extends HookConsumerWidget {
|
|||||||
Radius.circular(8.0),
|
Radius.circular(8.0),
|
||||||
),
|
),
|
||||||
onTap: onDelete,
|
onTap: onDelete,
|
||||||
onLongPress: onDeleteAll != null
|
onLongPress:
|
||||||
|
onDeleteAll != null || onCloseSubtree != null
|
||||||
? () {
|
? () {
|
||||||
if (extendedDeleteMenuController.isOpen) {
|
if (extendedDeleteMenuController.isOpen) {
|
||||||
extendedDeleteMenuController.close();
|
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(
|
Positioned(
|
||||||
top: 6.0,
|
top: 6.0,
|
||||||
left: 6.0,
|
left: 6.0,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
if (groupToggle != null) ...[
|
||||||
|
groupToggle!,
|
||||||
|
if (trailingChild != null || isPinned)
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
],
|
||||||
if (trailingChild != null) trailingChild!,
|
if (trailingChild != null) trailingChild!,
|
||||||
if (isPinned)
|
if (isPinned)
|
||||||
Padding(
|
Padding(
|
||||||
@@ -400,19 +435,31 @@ class ListTabPreview extends HookConsumerWidget {
|
|||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final VoidCallback? onDelete;
|
final VoidCallback? onDelete;
|
||||||
final void Function(String host)? onDeleteAll;
|
final void Function(String host)? onDeleteAll;
|
||||||
|
final VoidCallback? onCloseSubtree;
|
||||||
|
|
||||||
final bool showPinBadge;
|
final bool showPinBadge;
|
||||||
|
|
||||||
final Widget? trailingChild;
|
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({
|
const ListTabPreview({
|
||||||
required this.tabId,
|
required this.tabId,
|
||||||
required this.isActive,
|
required this.isActive,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.onDelete,
|
this.onDelete,
|
||||||
this.onDeleteAll,
|
this.onDeleteAll,
|
||||||
|
this.onCloseSubtree,
|
||||||
this.showPinBadge = false,
|
this.showPinBadge = false,
|
||||||
this.trailingChild,
|
this.trailingChild,
|
||||||
|
this.groupToggle,
|
||||||
|
this.depth = 0,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -488,7 +535,7 @@ class ListTabPreview extends HookConsumerWidget {
|
|||||||
|
|
||||||
const borderRadius = BorderRadius.all(Radius.circular(12.0));
|
const borderRadius = BorderRadius.all(Radius.circular(12.0));
|
||||||
|
|
||||||
return Container(
|
final card = Container(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 4.0),
|
margin: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 4.0),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: listBgColor,
|
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(
|
MenuAnchor(
|
||||||
controller: extendedDeleteMenuController,
|
controller: extendedDeleteMenuController,
|
||||||
builder: (context, controller, child) {
|
builder: (context, controller, child) {
|
||||||
@@ -571,10 +621,16 @@ class ListTabPreview extends HookConsumerWidget {
|
|||||||
leadingIcon: const Icon(Icons.language),
|
leadingIcon: const Icon(Icons.language),
|
||||||
child: const Text('Close from Same Host'),
|
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(
|
child: IconButton(
|
||||||
onPressed: onDelete,
|
onPressed: onDelete,
|
||||||
onLongPress: onDeleteAll != null
|
onLongPress: onDeleteAll != null || onCloseSubtree != null
|
||||||
? () {
|
? () {
|
||||||
if (extendedDeleteMenuController.isOpen) {
|
if (extendedDeleteMenuController.isOpen) {
|
||||||
extendedDeleteMenuController.close();
|
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 {
|
class SyncedListTabPreview extends StatelessWidget {
|
||||||
const SyncedListTabPreview({
|
const SyncedListTabPreview({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -654,6 +803,9 @@ class SingleGridTabPreview extends HookConsumerWidget {
|
|||||||
final void Function() onClose;
|
final void Function() onClose;
|
||||||
final void Function()? onBeforeDelete;
|
final void Function()? onBeforeDelete;
|
||||||
|
|
||||||
|
final Widget? groupToggle;
|
||||||
|
final int depth;
|
||||||
|
|
||||||
const SingleGridTabPreview({
|
const SingleGridTabPreview({
|
||||||
required this.tabId,
|
required this.tabId,
|
||||||
required this.activeTabId,
|
required this.activeTabId,
|
||||||
@@ -661,6 +813,8 @@ class SingleGridTabPreview extends HookConsumerWidget {
|
|||||||
required this.sourceSearchQuery,
|
required this.sourceSearchQuery,
|
||||||
this.deleteThreshold = 100,
|
this.deleteThreshold = 100,
|
||||||
this.onBeforeDelete,
|
this.onBeforeDelete,
|
||||||
|
this.groupToggle,
|
||||||
|
this.depth = 0,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -707,6 +861,8 @@ class SingleGridTabPreview extends HookConsumerWidget {
|
|||||||
tabId: tabId,
|
tabId: tabId,
|
||||||
isActive: tabId == activeTabId,
|
isActive: tabId == activeTabId,
|
||||||
showPinBadge: true,
|
showPinBadge: true,
|
||||||
|
groupToggle: groupToggle,
|
||||||
|
depth: depth,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
if (tabId != activeTabId) {
|
if (tabId != activeTabId) {
|
||||||
//Close first to avoid rebuilds
|
//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 {
|
onDelete: () async {
|
||||||
onBeforeDelete?.call();
|
onBeforeDelete?.call();
|
||||||
|
|
||||||
@@ -771,6 +948,9 @@ class SingleListTabPreview extends HookConsumerWidget {
|
|||||||
final void Function() onClose;
|
final void Function() onClose;
|
||||||
final void Function()? onBeforeDelete;
|
final void Function()? onBeforeDelete;
|
||||||
|
|
||||||
|
final Widget? groupToggle;
|
||||||
|
final int depth;
|
||||||
|
|
||||||
const SingleListTabPreview({
|
const SingleListTabPreview({
|
||||||
required this.tabId,
|
required this.tabId,
|
||||||
required this.activeTabId,
|
required this.activeTabId,
|
||||||
@@ -778,6 +958,8 @@ class SingleListTabPreview extends HookConsumerWidget {
|
|||||||
required this.sourceSearchQuery,
|
required this.sourceSearchQuery,
|
||||||
this.deleteThreshold = 100,
|
this.deleteThreshold = 100,
|
||||||
this.onBeforeDelete,
|
this.onBeforeDelete,
|
||||||
|
this.groupToggle,
|
||||||
|
this.depth = 0,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -824,6 +1006,8 @@ class SingleListTabPreview extends HookConsumerWidget {
|
|||||||
tabId: tabId,
|
tabId: tabId,
|
||||||
isActive: tabId == activeTabId,
|
isActive: tabId == activeTabId,
|
||||||
showPinBadge: true,
|
showPinBadge: true,
|
||||||
|
groupToggle: groupToggle,
|
||||||
|
depth: depth,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
if (tabId != activeTabId) {
|
if (tabId != activeTabId) {
|
||||||
//Close first to avoid rebuilds
|
//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 {
|
onDelete: () async {
|
||||||
onBeforeDelete?.call();
|
onBeforeDelete?.call();
|
||||||
|
|
||||||
|
|||||||
+18
@@ -427,6 +427,24 @@ class TabViewHeader extends HookConsumerWidget {
|
|||||||
],
|
],
|
||||||
child: const Text('Sort'),
|
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(),
|
const Divider(),
|
||||||
// Date range picker
|
// Date range picker
|
||||||
MenuItemButton(
|
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() =>
|
String _$startupPreferenceEnforcementServiceHash() =>
|
||||||
r'5cfe5aec33a9e9e077704ff519c10b0398c5085d';
|
r'074e09b7f3abd430946dce87ef1c529597e97866';
|
||||||
|
|
||||||
abstract class _$StartupPreferenceEnforcementService extends $Notifier<void> {
|
abstract class _$StartupPreferenceEnforcementService extends $Notifier<void> {
|
||||||
void build();
|
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(
|
SingleOrNullSelectable<String> generateOrderKeyAfterTabId(
|
||||||
String? containerId,
|
String? containerId,
|
||||||
String tabId,
|
String tabId,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
*/
|
*/
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:lexo_rank/lexo_rank.dart';
|
import 'package:lexo_rank/lexo_rank.dart';
|
||||||
import 'package:nullability/nullability.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/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/container_data.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/tab_query_result.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 {
|
class SyncTabsResult {
|
||||||
final Set<String> deletedIsolationContextIds;
|
final Set<String> deletedIsolationContextIds;
|
||||||
@@ -154,35 +154,54 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
|
|||||||
Future<String> _generateOrderKey({
|
Future<String> _generateOrderKey({
|
||||||
required Value<String?> parentId,
|
required Value<String?> parentId,
|
||||||
required Value<String?> containerId,
|
required Value<String?> containerId,
|
||||||
required NewTabPosition newTabPosition,
|
Value<String?> afterTabId = const Value.absent(),
|
||||||
}) async {
|
}) async {
|
||||||
if (parentId.value.isNotEmpty) {
|
// Explicit "place after this tab" wins regardless of parent.
|
||||||
return await db.containerDao
|
if (afterTabId.present && afterTabId.value != null) {
|
||||||
.generateOrderKeyAfterTabId(containerId.value, parentId.value!)
|
final key = await db.containerDao
|
||||||
.getSingleOrNull() ??
|
.generateOrderKeyAfterTabId(containerId.value, afterTabId.value!)
|
||||||
await db.containerDao
|
.getSingleOrNull();
|
||||||
.generateLeadingOrderKey(containerId.value)
|
if (key != null) {
|
||||||
.getSingle();
|
return key;
|
||||||
} else {
|
}
|
||||||
return switch (newTabPosition) {
|
|
||||||
NewTabPosition.first =>
|
|
||||||
db.containerDao
|
|
||||||
.generateLeadingOrderKey(containerId.value)
|
|
||||||
.getSingle(),
|
|
||||||
NewTabPosition.end =>
|
|
||||||
db.containerDao
|
|
||||||
.generateTrailingOrderKey(containerId.value)
|
|
||||||
.getSingle(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> upsertTabTransactional(
|
||||||
Future<String> Function() createTab, {
|
Future<String> Function() createTab, {
|
||||||
required Value<String?> parentId,
|
required Value<String?> parentId,
|
||||||
NewTabPosition newTabPosition = NewTabPosition.first,
|
|
||||||
Value<String?> containerId = const Value.absent(),
|
Value<String?> containerId = const Value.absent(),
|
||||||
Value<String?> orderKey = const Value.absent(),
|
Value<String?> orderKey = const Value.absent(),
|
||||||
|
Value<String?> afterTabId = const Value.absent(),
|
||||||
Value<Uri?> url = const Value.absent(),
|
Value<Uri?> url = const Value.absent(),
|
||||||
Value<String?> title = const Value.absent(),
|
Value<String?> title = const Value.absent(),
|
||||||
Value<TabMode> tabMode = const Value.absent(),
|
Value<TabMode> tabMode = const Value.absent(),
|
||||||
@@ -194,7 +213,7 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
|
|||||||
await _generateOrderKey(
|
await _generateOrderKey(
|
||||||
parentId: parentId,
|
parentId: parentId,
|
||||||
containerId: containerId,
|
containerId: containerId,
|
||||||
newTabPosition: newTabPosition,
|
afterTabId: afterTabId,
|
||||||
);
|
);
|
||||||
final Value<TabModeDbValue> persistedTabMode = tabMode.present
|
final Value<TabModeDbValue> persistedTabMode = tabMode.present
|
||||||
? Value(tabMode.value.toDbValue())
|
? Value(tabMode.value.toDbValue())
|
||||||
@@ -239,9 +258,9 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
|
|||||||
String tabId, {
|
String tabId, {
|
||||||
required TabSource source,
|
required TabSource source,
|
||||||
required Value<String?> parentId,
|
required Value<String?> parentId,
|
||||||
NewTabPosition newTabPosition = NewTabPosition.first,
|
|
||||||
Value<String?> containerId = const Value.absent(),
|
Value<String?> containerId = const Value.absent(),
|
||||||
Value<String?> orderKey = const Value.absent(),
|
Value<String?> orderKey = const Value.absent(),
|
||||||
|
Value<String?> afterTabId = const Value.absent(),
|
||||||
Value<Uri?> url = const Value.absent(),
|
Value<Uri?> url = const Value.absent(),
|
||||||
Value<String?> title = const Value.absent(),
|
Value<String?> title = const Value.absent(),
|
||||||
Value<TabMode> tabMode = const Value.absent(),
|
Value<TabMode> tabMode = const Value.absent(),
|
||||||
@@ -252,7 +271,7 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
|
|||||||
await _generateOrderKey(
|
await _generateOrderKey(
|
||||||
parentId: parentId,
|
parentId: parentId,
|
||||||
containerId: containerId,
|
containerId: containerId,
|
||||||
newTabPosition: newTabPosition,
|
afterTabId: afterTabId,
|
||||||
);
|
);
|
||||||
final Value<TabModeDbValue> persistedTabMode = tabMode.present
|
final Value<TabModeDbValue> persistedTabMode = tabMode.present
|
||||||
? Value(tabMode.value.toDbValue())
|
? Value(tabMode.value.toDbValue())
|
||||||
@@ -298,9 +317,265 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
|
|||||||
return statement.write(TabCompanion(containerId: Value(containerId)));
|
return statement.write(TabCompanion(containerId: Value(containerId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> assignOrderKey(String id, {required String orderKey}) {
|
Future<void> reorderTabs({
|
||||||
final statement = _updateByIdStatement(id);
|
required List<String> movingTabIds,
|
||||||
return statement.write(TabCompanion(orderKey: Value(orderKey)));
|
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}) {
|
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) {
|
Future<List<String>> getUnassignedRegularTabsOlderThan(DateTime threshold) {
|
||||||
final query = selectOnly(db.tab)
|
final query = selectOnly(db.tab)
|
||||||
..addColumns([db.tab.id])
|
..addColumns([db.tab.id])
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import 'package:weblibre/features/search/domain/fts_tokenizer.dart';
|
|||||||
@DriftDatabase(include: {'definitions.drift'}, daos: [ContainerDao, TabDao])
|
@DriftDatabase(include: {'definitions.drift'}, daos: [ContainerDao, TabDao])
|
||||||
class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin {
|
class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin {
|
||||||
@override
|
@override
|
||||||
final int schemaVersion = 7;
|
final int schemaVersion = 8;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final int ftsTokenLimit = 10;
|
final int ftsTokenLimit = 10;
|
||||||
@@ -118,5 +118,24 @@ class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin {
|
|||||||
from6To7: (m, schema) async {
|
from6To7: (m, schema) async {
|
||||||
await m.addColumn(schema.tab, schema.tab.isPinned);
|
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 => [
|
List<i0.DatabaseSchemaEntity> get allSchemaEntities => [
|
||||||
container,
|
container,
|
||||||
tab,
|
tab,
|
||||||
|
i1.idxTabParentContainer,
|
||||||
tabFts,
|
tabFts,
|
||||||
i1.tabMaintainParentChainOnDelete,
|
i1.tabMaintainParentChainOnDelete,
|
||||||
i1.tabAfterInsert,
|
i1.tabAfterInsert,
|
||||||
|
|||||||
@@ -722,12 +722,100 @@ i1.GeneratedColumn<int> _column_19(String aliasedName) =>
|
|||||||
$customConstraints: 'NOT NULL DEFAULT 0',
|
$customConstraints: 'NOT NULL DEFAULT 0',
|
||||||
defaultValue: const i1.CustomExpression('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({
|
i0.MigrationStepWithVersion migrationSteps({
|
||||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
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, Schema4 schema) from3To4,
|
||||||
required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5,
|
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, Schema6 schema) from5To6,
|
||||||
required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
|
required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
|
||||||
}) {
|
}) {
|
||||||
return (currentVersion, database) async {
|
return (currentVersion, database) async {
|
||||||
switch (currentVersion) {
|
switch (currentVersion) {
|
||||||
@@ -756,6 +844,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
|||||||
final migrator = i1.Migrator(database, schema);
|
final migrator = i1.Migrator(database, schema);
|
||||||
await from6To7(migrator, schema);
|
await from6To7(migrator, schema);
|
||||||
return 7;
|
return 7;
|
||||||
|
case 7:
|
||||||
|
final schema = Schema8(database: database);
|
||||||
|
final migrator = i1.Migrator(database, schema);
|
||||||
|
await from7To8(migrator, schema);
|
||||||
|
return 8;
|
||||||
default:
|
default:
|
||||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
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, Schema5 schema) from4To5,
|
||||||
required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6,
|
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, Schema7 schema) from6To7,
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
|
||||||
}) => i0.VersionedSchema.stepByStepHelper(
|
}) => i0.VersionedSchema.stepByStepHelper(
|
||||||
step: migrationSteps(
|
step: migrationSteps(
|
||||||
from2To3: from2To3,
|
from2To3: from2To3,
|
||||||
@@ -775,5 +869,6 @@ i1.OnUpgrade stepByStep({
|
|||||||
from4To5: from4To5,
|
from4To5: from4To5,
|
||||||
from5To6: from5To6,
|
from5To6: from5To6,
|
||||||
from6To7: from6To7,
|
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(
|
USING fts5(
|
||||||
title,
|
title,
|
||||||
url,
|
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):
|
orderKeyAfterTab(:tab_id AS TEXT, REQUIRED :container_id AS TEXT OR NULL):
|
||||||
WITH ordered_table AS (
|
WITH ordered_table AS (
|
||||||
SELECT id,
|
SELECT id,
|
||||||
@@ -249,6 +261,61 @@ ON
|
|||||||
d.timestamp = rs.max_timestamp
|
d.timestamp = rs.max_timestamp
|
||||||
ORDER BY d.timestamp DESC;
|
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:
|
unorderedTabDescendants:
|
||||||
WITH RECURSIVE descendants AS (
|
WITH RECURSIVE descendants AS (
|
||||||
SELECT id, parent_id
|
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
|
class TabFts extends i0.Table
|
||||||
with i0.TableInfo<TabFts, i3.TabFt>, i0.VirtualTableInfo<TabFts, i3.TabFt> {
|
with i0.TableInfo<TabFts, i3.TabFt>, i0.VirtualTableInfo<TabFts, i3.TabFt> {
|
||||||
@override
|
@override
|
||||||
@@ -2406,6 +2411,20 @@ class DefinitionsDrift extends i9.ModularAccessor {
|
|||||||
).map((i0.QueryRow row) => row.read<String>('_c0'));
|
).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({
|
i0.Selectable<String> orderKeyAfterTab({
|
||||||
required String? containerId,
|
required String? containerId,
|
||||||
required String tabId,
|
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({
|
i0.Selectable<UnorderedTabDescendantsResult> unorderedTabDescendants({
|
||||||
required String tabId,
|
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 {
|
class UnorderedTabDescendantsResult {
|
||||||
final String id;
|
final String id;
|
||||||
final String? parentId;
|
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 {
|
class TabTreeEntity extends TabEntity {
|
||||||
@override
|
@override
|
||||||
final String tabId;
|
final String tabId;
|
||||||
|
|||||||
@@ -97,6 +97,17 @@ Stream<List<TabTreesResult>> watchTabTrees(Ref ref) {
|
|||||||
return db.definitionsDrift.tabTrees().watch();
|
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()
|
@Riverpod()
|
||||||
Stream<Map<String, String?>> watchTabDescendants(Ref ref, String tabId) {
|
Stream<Map<String, String?>> watchTabDescendants(Ref ref, String tabId) {
|
||||||
final db = ref.watch(tabDatabaseProvider);
|
final db = ref.watch(tabDatabaseProvider);
|
||||||
|
|||||||
@@ -370,6 +370,89 @@ final class WatchTabTreesProvider
|
|||||||
|
|
||||||
String _$watchTabTreesHash() => r'a2be591acb6818ea8675a12d90e6c4684a0448a8';
|
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)
|
@ProviderFor(watchTabDescendants)
|
||||||
final watchTabDescendantsProvider = WatchTabDescendantsFamily._();
|
final watchTabDescendantsProvider = WatchTabDescendantsFamily._();
|
||||||
|
|
||||||
|
|||||||
@@ -122,11 +122,19 @@ class TabDataRepository extends _$TabDataRepository {
|
|||||||
.setPinned(tabId, pinned: pinned);
|
.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
|
return ref
|
||||||
.read(tabDatabaseProvider)
|
.read(tabDatabaseProvider)
|
||||||
.tabDao
|
.tabDao
|
||||||
.assignOrderKey(tabId, orderKey: orderKey);
|
.reorderTabs(
|
||||||
|
movingTabIds: movingTabIds,
|
||||||
|
previousTabId: previousTabId,
|
||||||
|
nextTabId: nextTabId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> closeAllTabs({
|
Future<int> closeAllTabs({
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ final class TabDataRepositoryProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$tabDataRepositoryHash() => r'e6482b5408aa567f626d2f0a9455b2cc908ba84f';
|
String _$tabDataRepositoryHash() => r'e14144f832ce32e1ae349df8970d025036526bc0';
|
||||||
|
|
||||||
abstract class _$TabDataRepository extends $Notifier<void> {
|
abstract class _$TabDataRepository extends $Notifier<void> {
|
||||||
void build();
|
void build();
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ class _TabsSection extends StatelessWidget {
|
|||||||
SettingSection(name: 'Tabs'),
|
SettingSection(name: 'Tabs'),
|
||||||
_NewTabDefaultSection(),
|
_NewTabDefaultSection(),
|
||||||
_SmallWebTabDefaultSection(),
|
_SmallWebTabDefaultSection(),
|
||||||
_NewTabPositionSection(),
|
_TabListDirectionSection(),
|
||||||
|
_TabBarDirectionSection(),
|
||||||
_ShowContainerUiTile(),
|
_ShowContainerUiTile(),
|
||||||
_ShowIsolatedTabUiTile(),
|
_ShowIsolatedTabUiTile(),
|
||||||
_CreateChildTabsTile(),
|
_CreateChildTabsTile(),
|
||||||
@@ -340,13 +341,13 @@ class _ExternalLinkHandlingSection extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _NewTabPositionSection extends HookConsumerWidget {
|
class _TabListDirectionSection extends HookConsumerWidget {
|
||||||
const _NewTabPositionSection();
|
const _TabListDirectionSection();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final newTabPosition = ref.watch(
|
final direction = ref.watch(
|
||||||
generalSettingsWithDefaultsProvider.select((s) => s.newTabPosition),
|
generalSettingsWithDefaultsProvider.select((s) => s.tabListDirection),
|
||||||
);
|
);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -356,8 +357,65 @@ class _NewTabPositionSection extends HookConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const ListTile(
|
const ListTile(
|
||||||
title: Text('New Tab Position'),
|
title: Text('Tab List Direction'),
|
||||||
subtitle: Text('Choose where newly created tabs appear by default'),
|
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),
|
leading: Icon(MdiIcons.reorderHorizontal),
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
@@ -366,23 +424,23 @@ class _NewTabPositionSection extends HookConsumerWidget {
|
|||||||
showSelectedIcon: false,
|
showSelectedIcon: false,
|
||||||
segments: const [
|
segments: const [
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
value: NewTabPosition.first,
|
value: TabBarDirection.newestFirst,
|
||||||
label: Text('First'),
|
label: Text('Newest first'),
|
||||||
icon: Icon(MdiIcons.arrowCollapseLeft),
|
icon: Icon(MdiIcons.arrowCollapseLeft),
|
||||||
),
|
),
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
value: NewTabPosition.end,
|
value: TabBarDirection.oldestFirst,
|
||||||
label: Text('End'),
|
label: Text('Oldest first'),
|
||||||
icon: Icon(MdiIcons.arrowCollapseRight),
|
icon: Icon(MdiIcons.arrowCollapseRight),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
selected: {newTabPosition},
|
selected: {direction},
|
||||||
onSelectionChanged: (value) async {
|
onSelectionChanged: (value) async {
|
||||||
await ref
|
await ref
|
||||||
.read(saveGeneralSettingsControllerProvider.notifier)
|
.read(saveGeneralSettingsControllerProvider.notifier)
|
||||||
.save(
|
.save(
|
||||||
(currentSettings) =>
|
(currentSettings) =>
|
||||||
currentSettings.copyWith.newTabPosition(value.first),
|
currentSettings.copyWith.tabBarDirection(value.first),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
+34
-11
@@ -106,8 +106,13 @@ class ContextualToolbarSettingsScreen extends HookConsumerWidget {
|
|||||||
else
|
else
|
||||||
SliverReorderableList(
|
SliverReorderableList(
|
||||||
itemCount: visibleConfigs.length,
|
itemCount: visibleConfigs.length,
|
||||||
onReorder: (oldIndex, newIndex) =>
|
onReorder: (oldIndex, newIndex) => _onReorder(
|
||||||
_onReorder(visibleConfigs, oldIndex, newIndex, repository),
|
visibleConfigs,
|
||||||
|
oldIndex,
|
||||||
|
newIndex,
|
||||||
|
repository,
|
||||||
|
isVisible: true,
|
||||||
|
),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final config = visibleConfigs[index];
|
final config = visibleConfigs[index];
|
||||||
return _ToolbarButtonConfigTile(
|
return _ToolbarButtonConfigTile(
|
||||||
@@ -145,8 +150,13 @@ class ContextualToolbarSettingsScreen extends HookConsumerWidget {
|
|||||||
else
|
else
|
||||||
SliverReorderableList(
|
SliverReorderableList(
|
||||||
itemCount: hiddenConfigs.length,
|
itemCount: hiddenConfigs.length,
|
||||||
onReorder: (oldIndex, newIndex) =>
|
onReorder: (oldIndex, newIndex) => _onReorder(
|
||||||
_onReorder(hiddenConfigs, oldIndex, newIndex, repository),
|
hiddenConfigs,
|
||||||
|
oldIndex,
|
||||||
|
newIndex,
|
||||||
|
repository,
|
||||||
|
isVisible: false,
|
||||||
|
),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final config = hiddenConfigs[index];
|
final config = hiddenConfigs[index];
|
||||||
return _ToolbarButtonConfigTile(
|
return _ToolbarButtonConfigTile(
|
||||||
@@ -180,8 +190,9 @@ class ContextualToolbarSettingsScreen extends HookConsumerWidget {
|
|||||||
List<ToolbarButtonConfig> configs,
|
List<ToolbarButtonConfig> configs,
|
||||||
int oldIndex,
|
int oldIndex,
|
||||||
int newIndex,
|
int newIndex,
|
||||||
ContextualToolbarConfigRepository repository,
|
ContextualToolbarConfigRepository repository, {
|
||||||
) {
|
required bool isVisible,
|
||||||
|
}) {
|
||||||
if (oldIndex == newIndex) return;
|
if (oldIndex == newIndex) return;
|
||||||
final reorder = _resolveToolbarReorder(configs, oldIndex, newIndex);
|
final reorder = _resolveToolbarReorder(configs, oldIndex, newIndex);
|
||||||
if (reorder.targetIndex == oldIndex) return;
|
if (reorder.targetIndex == oldIndex) return;
|
||||||
@@ -192,6 +203,7 @@ class ContextualToolbarSettingsScreen extends HookConsumerWidget {
|
|||||||
oldIndex,
|
oldIndex,
|
||||||
reorder.targetIndex,
|
reorder.targetIndex,
|
||||||
reorder.movedId,
|
reorder.movedId,
|
||||||
|
isVisible: isVisible,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -201,22 +213,33 @@ class ContextualToolbarSettingsScreen extends HookConsumerWidget {
|
|||||||
List<ToolbarButtonConfig> configs,
|
List<ToolbarButtonConfig> configs,
|
||||||
int oldIndex,
|
int oldIndex,
|
||||||
int targetIndex,
|
int targetIndex,
|
||||||
String movedId,
|
String movedId, {
|
||||||
) async {
|
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;
|
final String orderKey;
|
||||||
if (targetIndex <= 0) {
|
if (targetIndex <= 0) {
|
||||||
orderKey = await repository.generateLeadingOrderKey();
|
orderKey = await repository.generateLeadingOrderKey(isVisible: isVisible);
|
||||||
} else if (targetIndex >= configs.length - 1) {
|
} else if (targetIndex >= configs.length - 1) {
|
||||||
orderKey = await repository.generateTrailingOrderKey();
|
orderKey = await repository.generateTrailingOrderKey(
|
||||||
|
isVisible: isVisible,
|
||||||
|
);
|
||||||
} else if (targetIndex < oldIndex) {
|
} else if (targetIndex < oldIndex) {
|
||||||
orderKey =
|
orderKey =
|
||||||
await repository.generateOrderKeyAfterButtonId(
|
await repository.generateOrderKeyAfterButtonId(
|
||||||
configs[targetIndex - 1].buttonId,
|
configs[targetIndex - 1].buttonId,
|
||||||
|
isVisible: isVisible,
|
||||||
) ??
|
) ??
|
||||||
await repository.generateLeadingOrderKey();
|
await repository.generateLeadingOrderKey(isVisible: isVisible);
|
||||||
} else {
|
} else {
|
||||||
orderKey = await repository.generateOrderKeyBeforeButtonId(
|
orderKey = await repository.generateOrderKeyBeforeButtonId(
|
||||||
configs[targetIndex + 1].buttonId,
|
configs[targetIndex + 1].buttonId,
|
||||||
|
isVisible: isVisible,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await repository.assignOrderKey(movedId, orderKey: orderKey);
|
await repository.assignOrderKey(movedId, orderKey: orderKey);
|
||||||
|
|||||||
@@ -44,9 +44,24 @@ class ToolbarButtonConfigDao extends DatabaseAccessor<UserDatabase>
|
|||||||
.write(ToolbarButtonConfigsCompanion(orderKey: Value(orderKey)));
|
.write(ToolbarButtonConfigsCompanion(orderKey: Value(orderKey)));
|
||||||
|
|
||||||
Future<void> assignVisibility(String buttonId, {required bool visible}) =>
|
Future<void> assignVisibility(String buttonId, {required bool visible}) =>
|
||||||
(update(db.toolbarButtonConfigs)
|
transaction(() async {
|
||||||
..where((t) => t.buttonId.equals(buttonId)))
|
// Land the toggled button at the trailing edge of its *new* section
|
||||||
.write(ToolbarButtonConfigsCompanion(isVisible: Value(visible)));
|
// 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) =>
|
Future<void> assignFallback(String buttonId, String? fallbackId) =>
|
||||||
(update(db.toolbarButtonConfigs)
|
(update(db.toolbarButtonConfigs)
|
||||||
@@ -74,7 +89,9 @@ class ToolbarButtonConfigDao extends DatabaseAccessor<UserDatabase>
|
|||||||
await transaction(() async {
|
await transaction(() async {
|
||||||
final inserted = <ToolbarButtonConfig>[];
|
final inserted = <ToolbarButtonConfig>[];
|
||||||
for (final def in missing) {
|
for (final def in missing) {
|
||||||
final orderKey = await generateTrailingOrderKey().getSingle();
|
final orderKey = await generateTrailingOrderKey(
|
||||||
|
isVisible: def.defaultVisible,
|
||||||
|
).getSingle();
|
||||||
inserted.add(
|
inserted.add(
|
||||||
ToolbarButtonConfig(
|
ToolbarButtonConfig(
|
||||||
buttonId: def.buttonId,
|
buttonId: def.buttonId,
|
||||||
@@ -96,18 +113,37 @@ class ToolbarButtonConfigDao extends DatabaseAccessor<UserDatabase>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
SingleSelectable<String> generateLeadingOrderKey({int bucket = 0}) =>
|
SingleSelectable<String> generateLeadingOrderKey({
|
||||||
db.definitionsDrift.toolbarLeadingOrderKey(bucket: bucket);
|
int bucket = 0,
|
||||||
|
required bool isVisible,
|
||||||
|
}) => db.definitionsDrift.toolbarLeadingOrderKey(
|
||||||
|
bucket: bucket,
|
||||||
|
isVisible: isVisible,
|
||||||
|
);
|
||||||
|
|
||||||
SingleSelectable<String> generateTrailingOrderKey({int bucket = 0}) =>
|
SingleSelectable<String> generateTrailingOrderKey({
|
||||||
db.definitionsDrift.toolbarTrailingOrderKey(bucket: bucket);
|
int bucket = 0,
|
||||||
|
required bool isVisible,
|
||||||
|
}) => db.definitionsDrift.toolbarTrailingOrderKey(
|
||||||
|
bucket: bucket,
|
||||||
|
isVisible: isVisible,
|
||||||
|
);
|
||||||
|
|
||||||
SingleOrNullSelectable<String> generateOrderKeyAfterButtonId(
|
SingleOrNullSelectable<String> generateOrderKeyAfterButtonId(
|
||||||
String buttonId,
|
String buttonId, {
|
||||||
) => db.definitionsDrift.toolbarOrderKeyAfterButton(buttonId: buttonId);
|
required bool isVisible,
|
||||||
|
}) => db.definitionsDrift.toolbarOrderKeyAfterButton(
|
||||||
|
buttonId: buttonId,
|
||||||
|
isVisible: isVisible,
|
||||||
|
);
|
||||||
|
|
||||||
SingleSelectable<String> generateOrderKeyBeforeButtonId(String buttonId) =>
|
SingleSelectable<String> generateOrderKeyBeforeButtonId(
|
||||||
db.definitionsDrift.toolbarOrderKeyBeforeButton(buttonId: buttonId);
|
String buttonId, {
|
||||||
|
required bool isVisible,
|
||||||
|
}) => db.definitionsDrift.toolbarOrderKeyBeforeButton(
|
||||||
|
buttonId: buttonId,
|
||||||
|
isVisible: isVisible,
|
||||||
|
);
|
||||||
|
|
||||||
Future<void> _insertWithDeferredFallbacks(
|
Future<void> _insertWithDeferredFallbacks(
|
||||||
List<ToolbarButtonConfig> configs,
|
List<ToolbarButtonConfig> configs,
|
||||||
|
|||||||
@@ -31,47 +31,56 @@ CREATE TABLE toolbar_button_configs (
|
|||||||
|
|
||||||
CREATE INDEX idx_toolbar_order_key ON toolbar_button_configs(order_key);
|
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(
|
SELECT lexo_rank_previous(
|
||||||
:bucket,
|
:bucket,
|
||||||
(
|
(
|
||||||
SELECT order_key
|
SELECT order_key
|
||||||
FROM toolbar_button_configs
|
FROM toolbar_button_configs
|
||||||
|
WHERE is_visible = :is_visible
|
||||||
ORDER BY order_key
|
ORDER BY order_key
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
toolbarTrailingOrderKey(:bucket AS INTEGER):
|
toolbarTrailingOrderKey(:bucket AS INTEGER, :is_visible AS BOOL):
|
||||||
SELECT lexo_rank_next(
|
SELECT lexo_rank_next(
|
||||||
:bucket,
|
:bucket,
|
||||||
(
|
(
|
||||||
SELECT order_key
|
SELECT order_key
|
||||||
FROM toolbar_button_configs
|
FROM toolbar_button_configs
|
||||||
|
WHERE is_visible = :is_visible
|
||||||
ORDER BY order_key DESC
|
ORDER BY order_key DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
toolbarOrderKeyAfterButton(:button_id AS TEXT):
|
toolbarOrderKeyAfterButton(:button_id AS TEXT, :is_visible AS BOOL):
|
||||||
WITH ordered_table AS (
|
WITH ordered_table AS (
|
||||||
SELECT
|
SELECT
|
||||||
button_id,
|
button_id,
|
||||||
order_key,
|
order_key,
|
||||||
LEAD(order_key) OVER (ORDER BY order_key) AS next_order_key
|
LEAD(order_key) OVER (ORDER BY order_key) AS next_order_key
|
||||||
FROM toolbar_button_configs
|
FROM toolbar_button_configs
|
||||||
|
WHERE is_visible = :is_visible
|
||||||
)
|
)
|
||||||
SELECT lexo_rank_reorder_after(order_key, next_order_key)
|
SELECT lexo_rank_reorder_after(order_key, next_order_key)
|
||||||
FROM ordered_table
|
FROM ordered_table
|
||||||
WHERE button_id = :button_id;
|
WHERE button_id = :button_id;
|
||||||
|
|
||||||
toolbarOrderKeyBeforeButton(:button_id AS TEXT):
|
toolbarOrderKeyBeforeButton(:button_id AS TEXT, :is_visible AS BOOL):
|
||||||
WITH ordered_table AS (
|
WITH ordered_table AS (
|
||||||
SELECT
|
SELECT
|
||||||
button_id,
|
button_id,
|
||||||
order_key,
|
order_key,
|
||||||
LAG(order_key) OVER (ORDER BY order_key) AS prev_order_key
|
LAG(order_key) OVER (ORDER BY order_key) AS prev_order_key
|
||||||
FROM toolbar_button_configs
|
FROM toolbar_button_configs
|
||||||
|
WHERE is_visible = :is_visible
|
||||||
)
|
)
|
||||||
SELECT lexo_rank_reorder_before(order_key, prev_order_key)
|
SELECT lexo_rank_reorder_before(order_key, prev_order_key)
|
||||||
FROM ordered_table
|
FROM ordered_table
|
||||||
|
|||||||
@@ -2024,36 +2024,46 @@ i0.Index get idxToolbarOrderKey => i0.Index(
|
|||||||
|
|
||||||
class DefinitionsDrift extends i3.ModularAccessor {
|
class DefinitionsDrift extends i3.ModularAccessor {
|
||||||
DefinitionsDrift(i0.GeneratedDatabase db) : super(db);
|
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(
|
return customSelect(
|
||||||
'SELECT lexo_rank_previous(?1, (SELECT order_key FROM toolbar_button_configs ORDER BY order_key LIMIT 1)) AS _c0',
|
'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)],
|
variables: [i0.Variable<int>(bucket), i0.Variable<bool>(isVisible)],
|
||||||
readsFrom: {toolbarButtonConfigs},
|
readsFrom: {toolbarButtonConfigs},
|
||||||
).map((i0.QueryRow row) => row.read<String>('_c0'));
|
).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(
|
return customSelect(
|
||||||
'SELECT lexo_rank_next(?1, (SELECT order_key FROM toolbar_button_configs ORDER BY order_key DESC LIMIT 1)) AS _c0',
|
'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)],
|
variables: [i0.Variable<int>(bucket), i0.Variable<bool>(isVisible)],
|
||||||
readsFrom: {toolbarButtonConfigs},
|
readsFrom: {toolbarButtonConfigs},
|
||||||
).map((i0.QueryRow row) => row.read<String>('_c0'));
|
).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(
|
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',
|
'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<String>(buttonId)],
|
variables: [i0.Variable<bool>(isVisible), i0.Variable<String>(buttonId)],
|
||||||
readsFrom: {toolbarButtonConfigs},
|
readsFrom: {toolbarButtonConfigs},
|
||||||
).map((i0.QueryRow row) => row.read<String>('_c0'));
|
).map((i0.QueryRow row) => row.read<String>('_c0'));
|
||||||
}
|
}
|
||||||
|
|
||||||
i0.Selectable<String> toolbarOrderKeyBeforeButton({
|
i0.Selectable<String> toolbarOrderKeyBeforeButton({
|
||||||
|
required bool isVisible,
|
||||||
required String buttonId,
|
required String buttonId,
|
||||||
}) {
|
}) {
|
||||||
return customSelect(
|
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',
|
'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<String>(buttonId)],
|
variables: [i0.Variable<bool>(isVisible), i0.Variable<String>(buttonId)],
|
||||||
readsFrom: {toolbarButtonConfigs},
|
readsFrom: {toolbarButtonConfigs},
|
||||||
).map((i0.QueryRow row) => row.read<String>('_c0'));
|
).map((i0.QueryRow row) => row.read<String>('_c0'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ enum QuickTabSwitcherMode { lastUsedTabs, containerTabs }
|
|||||||
|
|
||||||
enum TabIntentOpenSetting { regular, private, ask }
|
enum TabIntentOpenSetting { regular, private, ask }
|
||||||
|
|
||||||
enum NewTabPosition { first, end }
|
enum TabListDirection { newestFirst, oldestFirst }
|
||||||
|
|
||||||
|
enum TabBarDirection { newestFirst, oldestFirst }
|
||||||
|
|
||||||
enum TabBarPosition { top, bottom }
|
enum TabBarPosition { top, bottom }
|
||||||
|
|
||||||
@@ -86,7 +88,8 @@ class GeneralSettings with FastEquatable {
|
|||||||
final bool showIsolatedTabUi;
|
final bool showIsolatedTabUi;
|
||||||
@JsonKey(name: 'defaultCreateTabType')
|
@JsonKey(name: 'defaultCreateTabType')
|
||||||
final TabType storedDefaultCreateTabType;
|
final TabType storedDefaultCreateTabType;
|
||||||
final NewTabPosition newTabPosition;
|
final TabListDirection tabListDirection;
|
||||||
|
final TabBarDirection tabBarDirection;
|
||||||
final TabIntentOpenSetting tabIntentOpenSetting;
|
final TabIntentOpenSetting tabIntentOpenSetting;
|
||||||
final bool autoHideTabBar;
|
final bool autoHideTabBar;
|
||||||
final TabBarSwipeAction tabBarSwipeAction;
|
final TabBarSwipeAction tabBarSwipeAction;
|
||||||
@@ -140,7 +143,8 @@ class GeneralSettings with FastEquatable {
|
|||||||
required this.showContainerUi,
|
required this.showContainerUi,
|
||||||
required this.showIsolatedTabUi,
|
required this.showIsolatedTabUi,
|
||||||
required this.storedDefaultCreateTabType,
|
required this.storedDefaultCreateTabType,
|
||||||
required this.newTabPosition,
|
required this.tabListDirection,
|
||||||
|
required this.tabBarDirection,
|
||||||
required this.tabIntentOpenSetting,
|
required this.tabIntentOpenSetting,
|
||||||
required this.autoHideTabBar,
|
required this.autoHideTabBar,
|
||||||
required this.tabBarSwipeAction,
|
required this.tabBarSwipeAction,
|
||||||
@@ -195,7 +199,8 @@ class GeneralSettings with FastEquatable {
|
|||||||
bool? showContainerUi,
|
bool? showContainerUi,
|
||||||
bool? showIsolatedTabUi,
|
bool? showIsolatedTabUi,
|
||||||
TabType? storedDefaultCreateTabType,
|
TabType? storedDefaultCreateTabType,
|
||||||
NewTabPosition? newTabPosition,
|
TabListDirection? tabListDirection,
|
||||||
|
TabBarDirection? tabBarDirection,
|
||||||
TabIntentOpenSetting? tabIntentOpenSetting,
|
TabIntentOpenSetting? tabIntentOpenSetting,
|
||||||
bool? autoHideTabBar,
|
bool? autoHideTabBar,
|
||||||
TabBarSwipeAction? tabBarSwipeAction,
|
TabBarSwipeAction? tabBarSwipeAction,
|
||||||
@@ -248,7 +253,8 @@ class GeneralSettings with FastEquatable {
|
|||||||
showIsolatedTabUi = showIsolatedTabUi ?? true,
|
showIsolatedTabUi = showIsolatedTabUi ?? true,
|
||||||
storedDefaultCreateTabType =
|
storedDefaultCreateTabType =
|
||||||
storedDefaultCreateTabType ?? TabType.regular,
|
storedDefaultCreateTabType ?? TabType.regular,
|
||||||
newTabPosition = newTabPosition ?? NewTabPosition.first,
|
tabListDirection = tabListDirection ?? TabListDirection.newestFirst,
|
||||||
|
tabBarDirection = tabBarDirection ?? TabBarDirection.newestFirst,
|
||||||
tabIntentOpenSetting = tabIntentOpenSetting ?? TabIntentOpenSetting.ask,
|
tabIntentOpenSetting = tabIntentOpenSetting ?? TabIntentOpenSetting.ask,
|
||||||
autoHideTabBar = autoHideTabBar ?? true,
|
autoHideTabBar = autoHideTabBar ?? true,
|
||||||
tabBarSwipeAction =
|
tabBarSwipeAction =
|
||||||
@@ -295,8 +301,23 @@ class GeneralSettings with FastEquatable {
|
|||||||
blockExternalAppsEnabled = blockExternalAppsEnabled ?? false,
|
blockExternalAppsEnabled = blockExternalAppsEnabled ?? false,
|
||||||
externalAppIntentPolicies = externalAppIntentPolicies ?? const {};
|
externalAppIntentPolicies = externalAppIntentPolicies ?? const {};
|
||||||
|
|
||||||
factory GeneralSettings.fromJson(Map<String, dynamic> json) =>
|
factory GeneralSettings.fromJson(Map<String, dynamic> json) {
|
||||||
_$GeneralSettingsFromJson(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);
|
Map<String, dynamic> toJson() => _$GeneralSettingsToJson(this);
|
||||||
|
|
||||||
@@ -332,7 +353,8 @@ class GeneralSettings with FastEquatable {
|
|||||||
showContainerUi,
|
showContainerUi,
|
||||||
showIsolatedTabUi,
|
showIsolatedTabUi,
|
||||||
storedDefaultCreateTabType,
|
storedDefaultCreateTabType,
|
||||||
newTabPosition,
|
tabListDirection,
|
||||||
|
tabBarDirection,
|
||||||
tabIntentOpenSetting,
|
tabIntentOpenSetting,
|
||||||
autoHideTabBar,
|
autoHideTabBar,
|
||||||
tabBarSwipeAction,
|
tabBarSwipeAction,
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ abstract class _$GeneralSettingsCWProxy {
|
|||||||
TabType storedDefaultCreateTabType,
|
TabType storedDefaultCreateTabType,
|
||||||
);
|
);
|
||||||
|
|
||||||
GeneralSettings newTabPosition(NewTabPosition newTabPosition);
|
GeneralSettings tabListDirection(TabListDirection tabListDirection);
|
||||||
|
|
||||||
|
GeneralSettings tabBarDirection(TabBarDirection tabBarDirection);
|
||||||
|
|
||||||
GeneralSettings tabIntentOpenSetting(
|
GeneralSettings tabIntentOpenSetting(
|
||||||
TabIntentOpenSetting tabIntentOpenSetting,
|
TabIntentOpenSetting tabIntentOpenSetting,
|
||||||
@@ -154,7 +156,8 @@ abstract class _$GeneralSettingsCWProxy {
|
|||||||
bool showContainerUi,
|
bool showContainerUi,
|
||||||
bool showIsolatedTabUi,
|
bool showIsolatedTabUi,
|
||||||
TabType storedDefaultCreateTabType,
|
TabType storedDefaultCreateTabType,
|
||||||
NewTabPosition newTabPosition,
|
TabListDirection tabListDirection,
|
||||||
|
TabBarDirection tabBarDirection,
|
||||||
TabIntentOpenSetting tabIntentOpenSetting,
|
TabIntentOpenSetting tabIntentOpenSetting,
|
||||||
bool autoHideTabBar,
|
bool autoHideTabBar,
|
||||||
TabBarSwipeAction tabBarSwipeAction,
|
TabBarSwipeAction tabBarSwipeAction,
|
||||||
@@ -265,8 +268,12 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
|||||||
) => call(storedDefaultCreateTabType: storedDefaultCreateTabType);
|
) => call(storedDefaultCreateTabType: storedDefaultCreateTabType);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
GeneralSettings newTabPosition(NewTabPosition newTabPosition) =>
|
GeneralSettings tabListDirection(TabListDirection tabListDirection) =>
|
||||||
call(newTabPosition: newTabPosition);
|
call(tabListDirection: tabListDirection);
|
||||||
|
|
||||||
|
@override
|
||||||
|
GeneralSettings tabBarDirection(TabBarDirection tabBarDirection) =>
|
||||||
|
call(tabBarDirection: tabBarDirection);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
GeneralSettings tabIntentOpenSetting(
|
GeneralSettings tabIntentOpenSetting(
|
||||||
@@ -447,7 +454,8 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
|||||||
Object? showContainerUi = const $CopyWithPlaceholder(),
|
Object? showContainerUi = const $CopyWithPlaceholder(),
|
||||||
Object? showIsolatedTabUi = const $CopyWithPlaceholder(),
|
Object? showIsolatedTabUi = const $CopyWithPlaceholder(),
|
||||||
Object? storedDefaultCreateTabType = const $CopyWithPlaceholder(),
|
Object? storedDefaultCreateTabType = const $CopyWithPlaceholder(),
|
||||||
Object? newTabPosition = const $CopyWithPlaceholder(),
|
Object? tabListDirection = const $CopyWithPlaceholder(),
|
||||||
|
Object? tabBarDirection = const $CopyWithPlaceholder(),
|
||||||
Object? tabIntentOpenSetting = const $CopyWithPlaceholder(),
|
Object? tabIntentOpenSetting = const $CopyWithPlaceholder(),
|
||||||
Object? autoHideTabBar = const $CopyWithPlaceholder(),
|
Object? autoHideTabBar = const $CopyWithPlaceholder(),
|
||||||
Object? tabBarSwipeAction = const $CopyWithPlaceholder(),
|
Object? tabBarSwipeAction = const $CopyWithPlaceholder(),
|
||||||
@@ -572,12 +580,18 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
|||||||
? _value.storedDefaultCreateTabType
|
? _value.storedDefaultCreateTabType
|
||||||
// ignore: cast_nullable_to_non_nullable
|
// ignore: cast_nullable_to_non_nullable
|
||||||
: storedDefaultCreateTabType as TabType,
|
: storedDefaultCreateTabType as TabType,
|
||||||
newTabPosition:
|
tabListDirection:
|
||||||
newTabPosition == const $CopyWithPlaceholder() ||
|
tabListDirection == const $CopyWithPlaceholder() ||
|
||||||
newTabPosition == null
|
tabListDirection == null
|
||||||
? _value.newTabPosition
|
? _value.tabListDirection
|
||||||
// ignore: cast_nullable_to_non_nullable
|
// 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:
|
||||||
tabIntentOpenSetting == const $CopyWithPlaceholder() ||
|
tabIntentOpenSetting == const $CopyWithPlaceholder() ||
|
||||||
tabIntentOpenSetting == null
|
tabIntentOpenSetting == null
|
||||||
@@ -836,9 +850,13 @@ GeneralSettings _$GeneralSettingsFromJson(
|
|||||||
_$TabTypeEnumMap,
|
_$TabTypeEnumMap,
|
||||||
json['defaultCreateTabType'],
|
json['defaultCreateTabType'],
|
||||||
),
|
),
|
||||||
newTabPosition: $enumDecodeNullable(
|
tabListDirection: $enumDecodeNullable(
|
||||||
_$NewTabPositionEnumMap,
|
_$TabListDirectionEnumMap,
|
||||||
json['newTabPosition'],
|
json['tabListDirection'],
|
||||||
|
),
|
||||||
|
tabBarDirection: $enumDecodeNullable(
|
||||||
|
_$TabBarDirectionEnumMap,
|
||||||
|
json['tabBarDirection'],
|
||||||
),
|
),
|
||||||
tabIntentOpenSetting: $enumDecodeNullable(
|
tabIntentOpenSetting: $enumDecodeNullable(
|
||||||
_$TabIntentOpenSettingEnumMap,
|
_$TabIntentOpenSettingEnumMap,
|
||||||
@@ -937,7 +955,8 @@ Map<String, dynamic> _$GeneralSettingsToJson(
|
|||||||
'showIsolatedTabUi': instance.showIsolatedTabUi,
|
'showIsolatedTabUi': instance.showIsolatedTabUi,
|
||||||
'defaultCreateTabType':
|
'defaultCreateTabType':
|
||||||
_$TabTypeEnumMap[instance.storedDefaultCreateTabType]!,
|
_$TabTypeEnumMap[instance.storedDefaultCreateTabType]!,
|
||||||
'newTabPosition': _$NewTabPositionEnumMap[instance.newTabPosition]!,
|
'tabListDirection': _$TabListDirectionEnumMap[instance.tabListDirection]!,
|
||||||
|
'tabBarDirection': _$TabBarDirectionEnumMap[instance.tabBarDirection]!,
|
||||||
'tabIntentOpenSetting':
|
'tabIntentOpenSetting':
|
||||||
_$TabIntentOpenSettingEnumMap[instance.tabIntentOpenSetting]!,
|
_$TabIntentOpenSettingEnumMap[instance.tabIntentOpenSetting]!,
|
||||||
'autoHideTabBar': instance.autoHideTabBar,
|
'autoHideTabBar': instance.autoHideTabBar,
|
||||||
@@ -1012,9 +1031,14 @@ const _$TabTypeEnumMap = {
|
|||||||
TabType.isolated: 'isolated',
|
TabType.isolated: 'isolated',
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$NewTabPositionEnumMap = {
|
const _$TabListDirectionEnumMap = {
|
||||||
NewTabPosition.first: 'first',
|
TabListDirection.newestFirst: 'newestFirst',
|
||||||
NewTabPosition.end: 'end',
|
TabListDirection.oldestFirst: 'oldestFirst',
|
||||||
|
};
|
||||||
|
|
||||||
|
const _$TabBarDirectionEnumMap = {
|
||||||
|
TabBarDirection.newestFirst: 'newestFirst',
|
||||||
|
TabBarDirection.oldestFirst: 'oldestFirst',
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$TabIntentOpenSettingEnumMap = {
|
const _$TabIntentOpenSettingEnumMap = {
|
||||||
|
|||||||
@@ -106,6 +106,14 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository {
|
|||||||
DriftSqlType.string,
|
DriftSqlType.string,
|
||||||
db.typeMapping,
|
db.typeMapping,
|
||||||
),
|
),
|
||||||
|
'tabListDirection': settings['tabListDirection']?.readAs(
|
||||||
|
DriftSqlType.string,
|
||||||
|
db.typeMapping,
|
||||||
|
),
|
||||||
|
'tabBarDirection': settings['tabBarDirection']?.readAs(
|
||||||
|
DriftSqlType.string,
|
||||||
|
db.typeMapping,
|
||||||
|
),
|
||||||
'tabIntentOpenSetting': settings['tabIntentOpenSetting']?.readAs(
|
'tabIntentOpenSetting': settings['tabIntentOpenSetting']?.readAs(
|
||||||
DriftSqlType.string,
|
DriftSqlType.string,
|
||||||
db.typeMapping,
|
db.typeMapping,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$generalSettingsRepositoryHash() =>
|
String _$generalSettingsRepositoryHash() =>
|
||||||
r'f75d5cb1963c16404f5fd02db0097b72451717eb';
|
r'5fe717f8bccad163fa0cb8ec3294c4b00847e141';
|
||||||
|
|
||||||
abstract class _$GeneralSettingsRepository
|
abstract class _$GeneralSettingsRepository
|
||||||
extends $StreamNotifier<GeneralSettings> {
|
extends $StreamNotifier<GeneralSettings> {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'schema_v4.dart' as v4;
|
|||||||
import 'schema_v5.dart' as v5;
|
import 'schema_v5.dart' as v5;
|
||||||
import 'schema_v6.dart' as v6;
|
import 'schema_v6.dart' as v6;
|
||||||
import 'schema_v7.dart' as v7;
|
import 'schema_v7.dart' as v7;
|
||||||
|
import 'schema_v8.dart' as v8;
|
||||||
|
|
||||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||||
@override
|
@override
|
||||||
@@ -27,10 +28,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
|||||||
return v6.DatabaseAtV6(db);
|
return v6.DatabaseAtV6(db);
|
||||||
case 7:
|
case 7:
|
||||||
return v7.DatabaseAtV7(db);
|
return v7.DatabaseAtV7(db);
|
||||||
|
case 8:
|
||||||
|
return v8.DatabaseAtV8(db);
|
||||||
default:
|
default:
|
||||||
throw MissingSchemaException(version, versions);
|
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