From d976964387a038df5894920a2c2e59b24e2c952b Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Tue, 26 May 2026 10:26:26 +0200 Subject: [PATCH] hierarchy manipulation --- .../presentation/utils/tab_view_reorder.dart | 157 +++----- .../presentation/widgets/tab_menu.dart | 82 +++++ .../tab_view/dialogs/tab_parent_picker.dart | 198 ++++++++++ .../widgets/tab_view/tab_grid_view.dart | 1 + .../widgets/tab_view/tab_list_view.dart | 1 + .../features/tabs/data/database/daos/tab.dart | 345 +++++++++++++++++- .../domain/entities/tab_parent_change.dart | 49 +++ .../features/tabs/domain/providers.dart | 6 + .../features/tabs/domain/providers.g.dart | 70 ++++ .../tabs/domain/repositories/tab.dart | 30 ++ .../tabs/domain/repositories/tab.g.dart | 2 +- .../drift/tabs/tab_hierarchy_order_test.dart | 133 +++++++ 12 files changed, 959 insertions(+), 115 deletions(-) create mode 100644 apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/dialogs/tab_parent_picker.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/tabs/domain/entities/tab_parent_change.dart create mode 100644 apps/weblibre/test/drift/tabs/tab_hierarchy_order_test.dart diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/utils/tab_view_reorder.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/utils/tab_view_reorder.dart index a45ad73a..65b00673 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/utils/tab_view_reorder.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/utils/tab_view_reorder.dart @@ -20,6 +20,7 @@ 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/geckoview/features/tabs/domain/entities/tab_parent_change.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; class TabViewReorderResult { @@ -27,10 +28,16 @@ class TabViewReorderResult { final String? previousTabId; final String? nextTabId; + /// Parent assignment implied by the drop position. `unchanged` for plain + /// reorders; `detach` / `toParent` only emitted in hierarchical mode when + /// the drop lands outside the moving root's current parent scope. + final TabParentChange parentChange; + const TabViewReorderResult({ required this.movingTabIds, required this.previousTabId, required this.nextTabId, + this.parentChange = const TabParentChange.unchanged(), }); } @@ -108,21 +115,31 @@ TabViewReorderResult? buildTabViewReorderResult({ return null; } - final parentScope = _parentScope(movingItem); - final resolvedInsertIndex = _resolveInsertIndexInParentScope( + // Derive the new parent scope purely from the visual drop position. The + // rule is "adopt the parent of the item you dropped above"; when dropped + // past the last visible item, adopt the parent of that last item. This + // unifies plain reorder ("same parent as the target slot") and + // drag-to-reparent ("different parent than the moving root") behind a + // single predicate. + final String? newParentScope = _dropParentScopeFor( 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', - ); + + // Cycle guard: the new parent must not be inside the moving subtree. + if (newParentScope != null && moveBlockIds.contains(newParentScope)) { + logger.t('reorder refused: new parent scope is inside moving subtree'); return null; } + final originalParentScope = _parentScope(movingItem); + final TabParentChange parentChange = newParentScope == originalParentScope + ? const TabParentChange.unchanged() + : newParentScope == null + ? const TabParentChange.detach() + : TabParentChange.toParent(newParentScope); + final resolvedInsertIndex = requestedInsertIndex.clamp(0, remaining.length); + final reorderedBlocks = remaining.toList() ..insert(resolvedInsertIndex, movingItem); @@ -204,9 +221,26 @@ TabViewReorderResult? buildTabViewReorderResult({ movingPartitionRootId: _rootIdFor(movingItem.tabId, parentById), sortPinnedFirst: sortPinnedFirst, ), + parentChange: parentChange, ); } +/// Returns the parent scope implied by dropping just before +/// `remaining[insertIndex]` (or "at the end" when `insertIndex == length`). +/// +/// Rule: adopt the parent of the item you dropped above. For a tail-drop, +/// adopt the parent of the last item. The resulting scope is the +/// candidate `parent_id` for the moving subtree's root. +String? _dropParentScopeFor(List remaining, int insertIndex) { + if (remaining.isEmpty) { + return null; + } + if (insertIndex < remaining.length) { + return _parentScope(remaining[insertIndex]); + } + return _parentScope(remaining.last); +} + List _orderedIdsForStorageAnchors( List orderedTabIds, { required TabDirection tabListDirection, @@ -237,6 +271,7 @@ List _orderedIdsForStorageAnchors( TabViewReorderResult? _resultFromOrderedIds({ required List movingTabIds, required List orderedTabIds, + TabParentChange parentChange = const TabParentChange.unchanged(), }) { if (movingTabIds.isEmpty) { return null; @@ -260,114 +295,12 @@ TabViewReorderResult? _resultFromOrderedIds({ nextTabId: lastIndex + 1 < orderedTabIds.length ? orderedTabIds[lastIndex + 1] : null, + parentChange: parentChange, ); } String? _parentScope(TabViewItem item) => item.childItem?.parentId; -int? _resolveInsertIndexInParentScope( - List items, - int requestedInsertIndex, - String? parentScope, - Map parentById, -) { - final target = requestedInsertIndex < items.length - ? items[requestedInsertIndex] - : null; - - if (target != null && _isDirectScopeAnchor(target, parentScope, parentById)) { - return target.tabId == parentScope - ? requestedInsertIndex + 1 - : requestedInsertIndex; - } - - final previousAnchorIndex = _nearestPreviousScopeAnchorIndex( - items, - requestedInsertIndex - 1, - parentScope, - parentById, - ); - if (previousAnchorIndex != null) { - return _indexAfterVisibleSubtree(items, previousAnchorIndex, parentById); - } - - return _nearestNextScopeAnchorIndex( - items, - requestedInsertIndex, - parentScope, - parentById, - ); -} - -bool _isDirectScopeAnchor( - TabViewItem item, - String? parentScope, - Map parentById, -) { - return item.tabId == parentScope || parentById[item.tabId] == parentScope; -} - -int? _nearestPreviousScopeAnchorIndex( - List items, - int startIndex, - String? parentScope, - Map parentById, -) { - for (var i = startIndex; i >= 0; i--) { - if (_isDirectScopeAnchor(items[i], parentScope, parentById)) { - return i; - } - } - return null; -} - -int? _nearestNextScopeAnchorIndex( - List items, - int startIndex, - String? parentScope, - Map parentById, -) { - for (var i = startIndex; i < items.length; i++) { - if (_isDirectScopeAnchor(items[i], parentScope, parentById)) { - return items[i].tabId == parentScope ? i + 1 : i; - } - } - return null; -} - -int _indexAfterVisibleSubtree( - List items, - int anchorIndex, - Map parentById, -) { - final anchorId = items[anchorIndex].tabId; - var index = anchorIndex + 1; - while (index < items.length && - _isDescendantOf(items[index].tabId, anchorId, parentById)) { - index++; - } - return index; -} - -bool _isDescendantOf( - String tabId, - String ancestorId, - Map parentById, -) { - var parentId = parentById[tabId]; - final seen = {tabId}; - while (parentId != null) { - if (parentId == ancestorId) { - return true; - } - if (!seen.add(parentId)) { - return false; - } - parentId = parentById[parentId]; - } - return false; -} - Map> _buildChildrenByParent( Map rowsById, Map parentById, diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart index 7c43bf8d..15aea9f8 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart @@ -38,6 +38,7 @@ import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/navigation_buttons.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/dialogs/tab_parent_picker.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/translation_bottom_sheet.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart'; import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart'; @@ -50,6 +51,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/entities/contai import 'package:weblibre/features/geckoview/features/tabs/domain/providers.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/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; import 'package:weblibre/presentation/hooks/menu_controller.dart'; @@ -74,6 +76,7 @@ class TabMenu extends HookConsumerWidget { final bool enablePinTab; final bool enableReloadButton; final bool enableNavigationButtons; + final bool enableHierarchy; const TabMenu({ super.key, @@ -94,6 +97,7 @@ class TabMenu extends HookConsumerWidget { this.enablePinTab = true, this.enableReloadButton = true, this.enableNavigationButtons = true, + this.enableHierarchy = true, }); @override @@ -560,6 +564,84 @@ class TabMenu extends HookConsumerWidget { leadingIcon: const Icon(MdiIcons.folder), child: const Text('Container'), ), + if (enableHierarchy) + Consumer( + builder: (childContext, childRef, child) { + // `MenuItemButton.onPressed` is dispatched as a post-frame + // callback by Flutter's menu_anchor — by the time it fires, + // this `Consumer` element (and any context/ref captured from + // its builder params) has been deactivated as the menu + // overlay tears down. So: + // - use `childContext` / `childRef` only synchronously + // inside this builder (the `watch` below), + // - inside `onPressed`, use the outer `context` and `ref` + // from TabMenu.build, which live above the menu overlay + // and stay mounted with the trigger button. + final movingTab = childRef.watch( + watchTabDbDataProvider(selectedTabId), + ); + final tabData = movingTab.value; + final hasParent = tabData?.parentId != null; + + final repo = ref.read(tabDataRepositoryProvider.notifier); + + return SubmenuButton( + leadingIcon: const Icon(MdiIcons.fileTree), + menuChildren: [ + MenuItemButton( + leadingIcon: const Icon(MdiIcons.swapHorizontal), + onPressed: () async { + controller.close(); + await showTabParentPicker( + context: context, + ref: ref, + tabId: selectedTabId, + ); + }, + child: const Text('Change parent…'), + ), + MenuItemButton( + leadingIcon: const Icon(MdiIcons.fileTreeOutline), + onPressed: hasParent + ? () async { + await repo.setTabParent( + tabId: selectedTabId, + newParentId: null, + ); + } + : null, + child: const Text('Detach from parent'), + ), + const Divider(), + MenuItemButton( + leadingIcon: const Icon(MdiIcons.chevronUp), + onPressed: () async { + await repo.moveTabAmongSiblings( + selectedTabId, + down: + settings.tabListDirection == + TabDirection.newestFirst, + ); + }, + child: const Text('Move up'), + ), + MenuItemButton( + leadingIcon: const Icon(MdiIcons.chevronDown), + onPressed: () async { + await repo.moveTabAmongSiblings( + selectedTabId, + down: + settings.tabListDirection != + TabDirection.newestFirst, + ); + }, + child: const Text('Move down'), + ), + ], + child: const Text('Hierarchy'), + ); + }, + ), if (enableShare) SubmenuButton( menuChildren: [ diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/dialogs/tab_parent_picker.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/dialogs/tab_parent_picker.dart new file mode 100644 index 00000000..df61ae6f --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/dialogs/tab_parent_picker.dart @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; + +/// Matches the fixed itemExtent used by the main tab list +/// (`tab_list_view.dart`'s `_itemHeight`). +const double _itemExtent = 86.0; + +/// Outcome of the parent picker sheet. Mirrors the +/// `ContainerSelectionResult` shape so the sheet can pop a discriminated +/// value rather than relying on sentinels or string conventions. +sealed class _ParentPickerSelection { + const _ParentPickerSelection(); +} + +class _ParentPickerDetach extends _ParentPickerSelection { + const _ParentPickerDetach(); +} + +class _ParentPickerSelected extends _ParentPickerSelection { + final String parentTabId; + const _ParentPickerSelected(this.parentTabId); +} + +/// Modal bottom sheet that lets the user pick a new parent for [tabId], +/// or detach it from its current parent. +/// +/// Candidates are all tabs in the moving tab's container, minus the moving +/// tab itself and its descendants (cycle guard). Tapping the persistent +/// "Make standalone" entry detaches the tab. +Future showTabParentPicker({ + required BuildContext context, + required WidgetRef ref, + required String tabId, +}) async { + // Capture the notifier synchronously: by the time the modal pops, + // the calling widget (and its `ref`) may have unmounted, but the + // Riverpod notifier itself is keepAlive and safe to use post-await. + final repo = ref.read(tabDataRepositoryProvider.notifier); + + final selection = await showModalBottomSheet<_ParentPickerSelection>( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (context) => _TabParentPickerSheet(tabId: tabId), + ); + + switch (selection) { + case null: + return; + case _ParentPickerDetach(): + await repo.setTabParent(tabId: tabId, newParentId: null); + case _ParentPickerSelected(:final parentTabId): + await repo.setTabParent(tabId: tabId, newParentId: parentTabId); + } +} + +class _TabParentPickerSheet extends HookConsumerWidget { + final String tabId; + + const _TabParentPickerSheet({required this.tabId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final movingTabAsync = ref.watch(watchTabDbDataProvider(tabId)); + final descendantsAsync = ref.watch(watchTabDescendantsProvider(tabId)); + + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.6, + minChildSize: 0.3, + maxChildSize: 0.95, + builder: (context, scrollController) { + return movingTabAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (movingTab) { + if (movingTab == null) { + return const Center(child: Text('Tab no longer exists')); + } + return descendantsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (descendants) { + final excluded = descendants.keys.toSet()..add(tabId); + final containerId = movingTab.containerId; + final candidatesAsync = ref.watch( + watchContainerTabsDataProvider(containerId), + ); + return candidatesAsync.when( + loading: () => + const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (tabs) { + final candidates = tabs + .where((t) => !excluded.contains(t.id)) + .toList(); + return CustomScrollView( + controller: scrollController, + slivers: [ + const SliverPadding( + padding: EdgeInsets.fromLTRB(16, 8, 16, 8), + sliver: SliverToBoxAdapter( + child: Text( + 'Choose a parent tab', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + SliverToBoxAdapter( + child: ListTile( + leading: const Icon(MdiIcons.fileTreeOutline), + title: const Text('Make standalone'), + subtitle: const Text( + 'Detach from current parent', + ), + enabled: movingTab.parentId != null, + onTap: () => Navigator.of( + context, + ).pop(const _ParentPickerDetach()), + ), + ), + const SliverToBoxAdapter(child: Divider(height: 1)), + if (candidates.isEmpty) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + 'No candidate tabs in this container.', + textAlign: TextAlign.center, + ), + ), + ) + else + // Match the tab list view's fixed itemExtent. + // `ListTabPreview` has no intrinsic height — its + // thumbnail variant uses `BoxFit.fitHeight`, which + // needs a bounded parent or it sizes to the + // image's native dimensions and overflows. + SliverFixedExtentList.builder( + itemExtent: _itemExtent, + itemCount: candidates.length, + itemBuilder: (context, index) { + final candidate = candidates[index]; + // `isActive` highlights the current parent + // with the same accent the tab list uses for + // the selected tab — semantically "this is + // the one you're currently nested under". + return ListTabPreview( + tabId: candidate.id, + isActive: + candidate.id == movingTab.parentId, + onTap: () => Navigator.of( + context, + ).pop(_ParentPickerSelected(candidate.id)), + ); + }, + ), + const SliverPadding( + padding: EdgeInsets.only(bottom: 16), + ), + ], + ); + }, + ); + }, + ); + }, + ); + }, + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart index 5bb3fda4..eee504b0 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart @@ -399,6 +399,7 @@ class _TabGridView extends HookConsumerWidget { movingTabIds: result.movingTabIds, previousTabId: result.previousTabId, nextTabId: result.nextTabId, + parentChange: result.parentChange, ); }, childBuilder: (reorderableItemBuilder) { diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart index 8b2e4395..f8a9225a 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart @@ -458,6 +458,7 @@ class _TabListView extends HookConsumerWidget { movingTabIds: result.movingTabIds, previousTabId: result.previousTabId, nextTabId: result.nextTabId, + parentChange: result.parentChange, ); }, itemBuilder: (context, index) { diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/tab.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/tab.dart index 7340e0a0..da647fa1 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/tab.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/tab.dart @@ -31,6 +31,7 @@ import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_source.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/tab_query_result.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/entities/tab_parent_change.dart'; class SyncTabsResult { final Set deletedIsolationContextIds; @@ -188,7 +189,14 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { .getLastChildTabId(containerId.value, parentId.value!) .getSingleOrNull(); - final anchorTabId = lastChildId ?? parentId.value!; + final lastChildSubtreeId = lastChildId == null + ? null + : await lastSubtreeTabIdByOrderKey( + lastChildId, + containerId: containerId.value, + ).getSingleOrNull(); + + final anchorTabId = lastChildSubtreeId ?? lastChildId ?? parentId.value!; final key = await db.containerDao .generateOrderKeyAfterTabId(containerId.value, anchorTabId) @@ -433,6 +441,7 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { required List movingTabIds, required String? previousTabId, required String? nextTabId, + TabParentChange parentChange = const TabParentChange.unchanged(), }) { if (movingTabIds.isEmpty) { return Future.value(); @@ -468,11 +477,46 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { nextRank: nextRank, ); + // Resolve the parent_id / container_id companion values once. + // + // For a `TabParentToSpecific` whose target row is missing, fall back + // to "unchanged" so we don't issue a parent_id FK violation — the + // caller passed a stale id, but the order_key change is still + // useful. The container cascade fires whenever the parent_id is + // being assigned to a concrete tab (including unassigned parents, + // container_id = null), since `tabsWithRootAndDepth` is + // container-scoped and a divergent child would vanish from + // hierarchical views. + Value parentValue = const Value.absent(); + Value containerValue = const Value.absent(); + switch (parentChange) { + case TabParentUnchanged(): + break; + case TabParentDetach(): + parentValue = const Value(null); + case TabParentToSpecific(:final parentTabId): + final parent = + anchors[parentTabId] ?? + await getTabDataById(parentTabId).getSingleOrNull(); + if (parent != null) { + parentValue = Value(parentTabId); + containerValue = Value(parent.containerId); + } + } + await batch((batch) { for (var i = 0; i < movingTabIds.length; i++) { + final isRoot = i == 0; batch.update( db.tab, - TabCompanion(orderKey: Value(orderKeys[i])), + TabCompanion( + orderKey: Value(orderKeys[i]), + // parent_id change applies only to the moving root; + // descendants keep their existing parent_id pointers. + parentId: isRoot ? parentValue : const Value.absent(), + // Container cascade applies to the whole subtree. + containerId: containerValue, + ), where: (t) => t.id.equals(movingTabIds[i]), ); } @@ -480,6 +524,303 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { }); } + /// Returns the recursive set of [tabId] and its descendants. + Future> _collectSubtreeIds(String tabId) async { + final rows = await db.definitionsDrift + .unorderedTabDescendants(tabId: tabId) + .get(); + return {for (final r in rows) r.id}; + } + + /// Re-parents [tabId] to [newParentId] (or detaches when null). + /// + /// Returns `true` on success, `false` when the move was rejected + /// (cycle, unknown tab, or no-op). + /// + /// - Cycle-safe: rejects when [newParentId] is the moving tab itself or + /// any of its descendants. + /// - When attaching to a non-null parent, the whole moving subtree adopts + /// the new parent's `container_id` (otherwise the row vanishes from + /// hierarchical views, which are container-scoped). + /// - Slots the moving subtree immediately after the new parent's last + /// existing child (or after the parent itself if it has none), as an + /// atomic order_key block. When detaching, order_keys are left + /// untouched — the tab simply becomes a root in its current slot. + Future setTabParent({ + required String tabId, + required String? newParentId, + }) { + if (tabId == newParentId) { + return Future.value(false); + } + + return db.transaction(() async { + final movingTab = await getTabDataById(tabId).getSingleOrNull(); + if (movingTab == null) { + return false; + } + if (movingTab.parentId == newParentId) { + return false; + } + + final subtreeIds = await _collectSubtreeIds(tabId); + + String? targetContainerId; + if (newParentId != null) { + if (subtreeIds.contains(newParentId)) { + return false; + } + final newParent = await getTabDataById(newParentId).getSingleOrNull(); + if (newParent == null) { + return false; + } + targetContainerId = newParent.containerId; + } else { + targetContainerId = movingTab.containerId; + } + + // Cascade container update for the whole subtree when crossing + // container boundaries. `tabsWithRootAndDepth` is container-scoped + // — a child whose `container_id` differs from its parent's would + // vanish from hierarchical views. + if (targetContainerId != movingTab.containerId) { + await (update(db.tab)..where((t) => t.id.isIn(subtreeIds))).write( + TabCompanion(containerId: Value(targetContainerId)), + ); + } + + if (newParentId == null) { + // Detach: keep existing order_keys. The row becomes a root in its + // current slot and the subtree under it stays intact. + await _updateByIdStatement( + tabId, + ).write(const TabCompanion(parentId: Value(null))); + return true; + } + + // Pull the (now container-adjusted) subtree rows so we can re-rank + // them as an atomic block. We must compute anchors BEFORE writing + // the new parent_id, otherwise `getLastChildTabId(newParentId)` + // would pick up the moving root itself as a sibling. + final subtreeRows = + await (select(db.tab) + ..where((t) => t.id.isIn(subtreeIds)) + ..orderBy([(t) => OrderingTerm.asc(t.orderKey)])) + .get(); + final orderedIds = subtreeRows.map((r) => r.id).toList(); + if (orderedIds.isEmpty) { + return true; + } + + final lastSiblingId = await db.containerDao + .getLastChildTabId(targetContainerId, newParentId) + .getSingleOrNull(); + final lastSiblingSubtreeId = lastSiblingId == null + ? null + : await lastSubtreeTabIdByOrderKey( + lastSiblingId, + containerId: targetContainerId, + ).getSingleOrNull(); + final anchorId = lastSiblingSubtreeId ?? lastSiblingId ?? newParentId; + + final anchorRow = await getTabDataById(anchorId).getSingleOrNull(); + if (anchorRow == null) { + return false; + } + final previousRank = LexoRank.parse(anchorRow.orderKey); + + // Next-rank = first non-subtree tab in the destination container with + // order_key strictly greater than the anchor. Skipping subtree members + // keeps the moved block compact even when the subtree's old keys + // sat near the anchor in storage. + final nextRow = + await (select(db.tab) + ..where((t) { + final containerEq = targetContainerId != null + ? t.containerId.equals(targetContainerId) + : t.containerId.isNull(); + return containerEq & + t.orderKey.isBiggerThanValue(anchorRow.orderKey) & + t.id.isNotIn(subtreeIds); + }) + ..orderBy([(t) => OrderingTerm.asc(t.orderKey)]) + ..limit(1)) + .getSingleOrNull(); + final nextRank = nextRow == null + ? null + : LexoRank.parse(nextRow.orderKey); + + final orderKeys = _generateOrderKeysBetween( + count: orderedIds.length, + previousRank: previousRank, + nextRank: nextRank, + ); + + await batch((batch) { + // parent_id change is applied on the moving root only; subtree + // descendants keep their existing parent_id pointers. + batch.update( + db.tab, + TabCompanion( + parentId: Value(newParentId), + orderKey: Value(orderKeys[0]), + ), + where: (t) => t.id.equals(orderedIds[0]), + ); + for (var i = 1; i < orderedIds.length; i++) { + batch.update( + db.tab, + TabCompanion(orderKey: Value(orderKeys[i])), + where: (t) => t.id.equals(orderedIds[i]), + ); + } + }); + + return true; + }); + } + + /// Swaps a direct child with its parent: the child takes the parent's + /// slot in the tree (parent_id + order_key), and the parent becomes a + /// child of the (formerly) child, placed after its existing siblings. + /// + /// Returns `false` when [childId] has no parent or the tabs cross a + /// container boundary (which would imply data corruption). + Future promoteChildToParent(String childId) { + return db.transaction(() async { + final child = await getTabDataById(childId).getSingleOrNull(); + if (child == null || child.parentId == null) { + return false; + } + final parentId = child.parentId!; + final parent = await getTabDataById(parentId).getSingleOrNull(); + if (parent == null) { + return false; + } + if (parent.containerId != child.containerId) { + return false; + } + + final containerId = child.containerId; + + // The (about-to-be-demoted) parent gets placed after the new + // parent's (= former child's) existing other children. We read the + // anchor's order_key BEFORE any writes — `generateOrderKeyAfterTabId` + // reads from storage, so the captured key reflects pre-swap state. + final lastChildOfChild = await db.containerDao + .getLastChildTabId(containerId, childId) + .getSingleOrNull(); + final lastChildSubtreeId = lastChildOfChild == null + ? null + : await lastSubtreeTabIdByOrderKey( + lastChildOfChild, + containerId: containerId, + ).getSingleOrNull(); + final anchorId = lastChildSubtreeId ?? lastChildOfChild ?? childId; + final newParentOrderKey = await db.containerDao + .generateOrderKeyAfterTabId(containerId, anchorId) + .getSingleOrNull(); + if (newParentOrderKey == null) { + return false; + } + + await batch((batch) { + batch.update( + db.tab, + TabCompanion( + parentId: Value(parent.parentId), + orderKey: Value(parent.orderKey), + ), + where: (t) => t.id.equals(childId), + ); + batch.update( + db.tab, + TabCompanion( + parentId: Value(childId), + orderKey: Value(newParentOrderKey), + ), + where: (t) => t.id.equals(parentId), + ); + }); + + return true; + }); + } + + /// Moves [tabId] one sibling slot up (or down) within its parent scope, + /// carrying its whole subtree as an atomic block. + /// + /// Returns `false` when the tab is unknown or already at the relevant + /// end of its sibling list. + Future moveTabAmongSiblings(String tabId, {required bool down}) { + // Transactional so the sibling-list read, subtree resolution, and the + // anchor lookup all observe the same DB snapshot. `reorderTabs` opens + // a nested savepoint internally, which is fine. + return db.transaction(() async { + final tab = await getTabDataById(tabId).getSingleOrNull(); + if (tab == null) { + return false; + } + + final siblings = + await (select(db.tab) + ..where((t) { + final containerEq = tab.containerId != null + ? t.containerId.equals(tab.containerId!) + : t.containerId.isNull(); + final parentEq = tab.parentId != null + ? t.parentId.equals(tab.parentId!) + : t.parentId.isNull(); + return containerEq & parentEq; + }) + ..orderBy([(t) => OrderingTerm.asc(t.orderKey)])) + .get(); + + final idx = siblings.indexWhere((s) => s.id == tabId); + if (idx < 0) { + return false; + } + final newIdx = down ? idx + 1 : idx - 1; + if (newIdx < 0 || newIdx >= siblings.length) { + return false; + } + + final reordered = siblings.toList()..removeAt(idx); + reordered.insert(newIdx, tab); + + final previousIdx = newIdx - 1; + final nextIdx = newIdx + 1; + final previousSiblingId = previousIdx >= 0 + ? reordered[previousIdx].id + : null; + final previousTabId = previousSiblingId == null + ? null + : await lastSubtreeTabIdByOrderKey( + previousSiblingId, + containerId: tab.containerId, + ).getSingleOrNull() ?? + previousSiblingId; + final nextTabId = nextIdx < reordered.length + ? reordered[nextIdx].id + : null; + + final subtreeIds = await _collectSubtreeIds(tabId); + final subtreeRows = + await (select(db.tab) + ..where((t) => t.id.isIn(subtreeIds)) + ..orderBy([(t) => OrderingTerm.asc(t.orderKey)])) + .get(); + final movingTabIds = subtreeRows.map((r) => r.id).toList(); + + await reorderTabs( + movingTabIds: movingTabIds, + previousTabId: previousTabId, + nextTabId: nextTabId, + ); + return true; + }); + } + List _generateOrderKeysBetween({ required int count, required LexoRank? previousRank, diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/entities/tab_parent_change.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/entities/tab_parent_change.dart new file mode 100644 index 00000000..0500afa9 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/entities/tab_parent_change.dart @@ -0,0 +1,49 @@ +/* + * 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 . + */ + +/// How a reorder/reparent operation should affect the moving tab's +/// `parent_id`. Distinguishes "leave parent_id alone" (the default for a +/// plain reorder) from "detach to root" and "attach to a specific tab". +/// +/// Mirrors the shape of [TabContainerSelection]. +sealed class TabParentChange { + const TabParentChange(); + + const factory TabParentChange.unchanged() = TabParentUnchanged; + + const factory TabParentChange.detach() = TabParentDetach; + + const factory TabParentChange.toParent(String parentTabId) = + TabParentToSpecific; +} + +final class TabParentUnchanged extends TabParentChange { + const TabParentUnchanged(); +} + +final class TabParentDetach extends TabParentChange { + const TabParentDetach(); +} + +final class TabParentToSpecific extends TabParentChange { + final String parentTabId; + + const TabParentToSpecific(this.parentTabId); +} diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.dart index fdc67093..2e110bc2 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.dart @@ -108,6 +108,12 @@ Stream> watchTabsWithRootAndDepth( .watch(); } +@Riverpod() +Stream watchTabDbData(Ref ref, String tabId) { + final db = ref.watch(tabDatabaseProvider); + return db.tabDao.getTabDataById(tabId).watchSingleOrNull(); +} + @Riverpod() Stream> watchTabDescendants(Ref ref, String tabId) { final db = ref.watch(tabDatabaseProvider); diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.g.dart index a2139e4d..6c89fea2 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.g.dart @@ -453,6 +453,76 @@ final class WatchTabsWithRootAndDepthFamily extends $Family String toString() => r'watchTabsWithRootAndDepthProvider'; } +@ProviderFor(watchTabDbData) +final watchTabDbDataProvider = WatchTabDbDataFamily._(); + +final class WatchTabDbDataProvider + extends + $FunctionalProvider, TabData?, Stream> + with $FutureModifier, $StreamProvider { + WatchTabDbDataProvider._({ + required WatchTabDbDataFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'watchTabDbDataProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$watchTabDbDataHash(); + + @override + String toString() { + return r'watchTabDbDataProvider' + '' + '($argument)'; + } + + @$internal + @override + $StreamProviderElement $createElement($ProviderPointer pointer) => + $StreamProviderElement(pointer); + + @override + Stream create(Ref ref) { + final argument = this.argument as String; + return watchTabDbData(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is WatchTabDbDataProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$watchTabDbDataHash() => r'c505be1cdeaffced2615fdb075ed8a9a2132754a'; + +final class WatchTabDbDataFamily extends $Family + with $FunctionalFamilyOverride, String> { + WatchTabDbDataFamily._() + : super( + retry: null, + name: r'watchTabDbDataProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + WatchTabDbDataProvider call(String tabId) => + WatchTabDbDataProvider._(argument: tabId, from: this); + + @override + String toString() => r'watchTabDbDataProvider'; +} + @ProviderFor(watchTabDescendants) final watchTabDescendantsProvider = WatchTabDescendantsFamily._(); diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.dart index fb5b734c..3f36a500 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.dart @@ -27,6 +27,7 @@ import 'package:weblibre/features/geckoview/features/tabs/data/database/definiti import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/entities/tab_parent_change.dart'; part 'tab.g.dart'; @@ -126,6 +127,7 @@ class TabDataRepository extends _$TabDataRepository { required List movingTabIds, required String? previousTabId, required String? nextTabId, + TabParentChange parentChange = const TabParentChange.unchanged(), }) { return ref .read(tabDatabaseProvider) @@ -134,9 +136,37 @@ class TabDataRepository extends _$TabDataRepository { movingTabIds: movingTabIds, previousTabId: previousTabId, nextTabId: nextTabId, + parentChange: parentChange, ); } + Future setTabParent({ + required String tabId, + required String? newParentId, + }) { + return ref + .read(tabDatabaseProvider) + .tabDao + .setTabParent(tabId: tabId, newParentId: newParentId); + } + + Future promoteChildToParent(String childId) { + return ref + .read(tabDatabaseProvider) + .tabDao + .promoteChildToParent(childId); + } + + Future moveTabAmongSiblings( + String tabId, { + required bool down, + }) { + return ref + .read(tabDatabaseProvider) + .tabDao + .moveTabAmongSiblings(tabId, down: down); + } + Future closeAllTabs({ bool includeRegular = true, bool includePrivate = true, diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart index 299d4195..9604f234 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart @@ -41,7 +41,7 @@ final class TabDataRepositoryProvider } } -String _$tabDataRepositoryHash() => r'e14144f832ce32e1ae349df8970d025036526bc0'; +String _$tabDataRepositoryHash() => r'2208b806a97f239a5b3e6ed7e6c395cbd0276bdd'; abstract class _$TabDataRepository extends $Notifier { void build(); diff --git a/apps/weblibre/test/drift/tabs/tab_hierarchy_order_test.dart b/apps/weblibre/test/drift/tabs/tab_hierarchy_order_test.dart new file mode 100644 index 00000000..6646bed1 --- /dev/null +++ b/apps/weblibre/test/drift/tabs/tab_hierarchy_order_test.dart @@ -0,0 +1,133 @@ +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lexo_rank/lexo_rank.dart'; +import 'package:weblibre/data/database/functions/lexo_rank_functions.dart'; +import 'package:weblibre/data/database/functions/url_functions.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart'; + +void main() { + late TabDatabase db; + + setUp(() { + db = TabDatabase( + NativeDatabase.memory( + setup: (database) { + registerLexorankFunctions(database); + registerUrlFunctions(database); + }, + ), + ); + }); + + tearDown(() async { + await db.close(); + }); + + test('setTabParent appends after the existing last child subtree', () async { + await _insertTabs(db, const [ + _TabFixture('parent'), + _TabFixture('existing-child', parentId: 'parent'), + _TabFixture('existing-grandchild', parentId: 'existing-child'), + _TabFixture('moving'), + _TabFixture('moving-child', parentId: 'moving'), + ]); + + final moved = await db.tabDao.setTabParent( + tabId: 'moving', + newParentId: 'parent', + ); + + expect(moved, isTrue); + expect(await _orderedTabIds(db), [ + 'parent', + 'existing-child', + 'existing-grandchild', + 'moving', + 'moving-child', + ]); + }); + + test( + 'promoteChildToParent demotes after the promoted child subtree', + () async { + await _insertTabs(db, const [ + _TabFixture('parent'), + _TabFixture('child', parentId: 'parent'), + _TabFixture('grandchild', parentId: 'child'), + _TabFixture('great-grandchild', parentId: 'grandchild'), + ]); + + final promoted = await db.tabDao.promoteChildToParent('child'); + + expect(promoted, isTrue); + expect(await _orderedTabIds(db), [ + 'child', + 'grandchild', + 'great-grandchild', + 'parent', + ]); + }, + ); + + test( + 'moveTabAmongSiblings moves down after the target sibling subtree', + () async { + await _insertTabs(db, const [ + _TabFixture('parent'), + _TabFixture('first', parentId: 'parent'), + _TabFixture('first-child', parentId: 'first'), + _TabFixture('second', parentId: 'parent'), + _TabFixture('second-child', parentId: 'second'), + ]); + + final moved = await db.tabDao.moveTabAmongSiblings('first', down: true); + + expect(moved, isTrue); + expect(await _orderedTabIds(db), [ + 'parent', + 'second', + 'second-child', + 'first', + 'first-child', + ]); + }, + ); +} + +Future _insertTabs(TabDatabase db, List<_TabFixture> tabs) async { + final orderKeys = _spacedOrderKeys(tabs.length); + + for (final (index, tab) in tabs.indexed) { + await db.tabDao.upsertTabTransactional( + () async => tab.id, + parentId: Value(tab.parentId), + orderKey: Value(orderKeys[index]), + ); + } +} + +Future> _orderedTabIds(TabDatabase db) { + return db.tabDao.getAllTabIds().get(); +} + +List _spacedOrderKeys(int count) { + var rank = LexoRank.middle(); + final orderKeys = []; + + for (var i = 0; i < count; i++) { + orderKeys.add(rank.value); + for (var gap = 0; gap < 4; gap++) { + rank = rank.genNext(); + } + } + + return orderKeys; +} + +class _TabFixture { + final String id; + final String? parentId; + + const _TabFixture(this.id, {this.parentId}); +}