hierarchy manipulation

This commit is contained in:
Fabian Freund
2026-05-26 10:26:26 +02:00
parent da90d07318
commit d976964387
12 changed files with 959 additions and 115 deletions
@@ -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<TabViewItem> remaining, int insertIndex) {
if (remaining.isEmpty) {
return null;
}
if (insertIndex < remaining.length) {
return _parentScope(remaining[insertIndex]);
}
return _parentScope(remaining.last);
}
List<String> _orderedIdsForStorageAnchors(
List<String> orderedTabIds, {
required TabDirection tabListDirection,
@@ -237,6 +271,7 @@ List<String> _orderedIdsForStorageAnchors(
TabViewReorderResult? _resultFromOrderedIds({
required List<String> movingTabIds,
required List<String> 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<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,
@@ -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: [
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<void> 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),
),
],
);
},
);
},
);
},
);
},
);
}
}
@@ -399,6 +399,7 @@ class _TabGridView extends HookConsumerWidget {
movingTabIds: result.movingTabIds,
previousTabId: result.previousTabId,
nextTabId: result.nextTabId,
parentChange: result.parentChange,
);
},
childBuilder: (reorderableItemBuilder) {
@@ -458,6 +458,7 @@ class _TabListView extends HookConsumerWidget {
movingTabIds: result.movingTabIds,
previousTabId: result.previousTabId,
nextTabId: result.nextTabId,
parentChange: result.parentChange,
);
},
itemBuilder: (context, index) {
@@ -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<String> deletedIsolationContextIds;
@@ -188,7 +189,14 @@ class TabDao extends DatabaseAccessor<TabDatabase> 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<TabDatabase> with $TabDaoMixin {
required List<String> 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<TabDatabase> 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<String?> parentValue = const Value.absent();
Value<String?> 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<TabDatabase> with $TabDaoMixin {
});
}
/// Returns the recursive set of [tabId] and its descendants.
Future<Set<String>> _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<bool> 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<bool> 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<bool> 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<String> _generateOrderKeysBetween({
required int count,
required LexoRank? previousRank,
@@ -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 <http://www.gnu.org/licenses/>.
*/
/// 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);
}
@@ -108,6 +108,12 @@ Stream<List<TabsWithRootAndDepthResult>> watchTabsWithRootAndDepth(
.watch();
}
@Riverpod()
Stream<TabData?> watchTabDbData(Ref ref, String tabId) {
final db = ref.watch(tabDatabaseProvider);
return db.tabDao.getTabDataById(tabId).watchSingleOrNull();
}
@Riverpod()
Stream<Map<String, String?>> watchTabDescendants(Ref ref, String tabId) {
final db = ref.watch(tabDatabaseProvider);
@@ -453,6 +453,76 @@ final class WatchTabsWithRootAndDepthFamily extends $Family
String toString() => r'watchTabsWithRootAndDepthProvider';
}
@ProviderFor(watchTabDbData)
final watchTabDbDataProvider = WatchTabDbDataFamily._();
final class WatchTabDbDataProvider
extends
$FunctionalProvider<AsyncValue<TabData?>, TabData?, Stream<TabData?>>
with $FutureModifier<TabData?>, $StreamProvider<TabData?> {
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<TabData?> $createElement($ProviderPointer pointer) =>
$StreamProviderElement(pointer);
@override
Stream<TabData?> 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<Stream<TabData?>, 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._();
@@ -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<String> 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<bool> setTabParent({
required String tabId,
required String? newParentId,
}) {
return ref
.read(tabDatabaseProvider)
.tabDao
.setTabParent(tabId: tabId, newParentId: newParentId);
}
Future<bool> promoteChildToParent(String childId) {
return ref
.read(tabDatabaseProvider)
.tabDao
.promoteChildToParent(childId);
}
Future<bool> moveTabAmongSiblings(
String tabId, {
required bool down,
}) {
return ref
.read(tabDatabaseProvider)
.tabDao
.moveTabAmongSiblings(tabId, down: down);
}
Future<int> closeAllTabs({
bool includeRegular = true,
bool includePrivate = true,
@@ -41,7 +41,7 @@ final class TabDataRepositoryProvider
}
}
String _$tabDataRepositoryHash() => r'e14144f832ce32e1ae349df8970d025036526bc0';
String _$tabDataRepositoryHash() => r'2208b806a97f239a5b3e6ed7e6c395cbd0276bdd';
abstract class _$TabDataRepository extends $Notifier<void> {
void build();
@@ -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<void> _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<List<String>> _orderedTabIds(TabDatabase db) {
return db.tabDao.getAllTabIds().get();
}
List<String> _spacedOrderKeys(int count) {
var rank = LexoRank.middle();
final orderKeys = <String>[];
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});
}