hierarchy manipulation
This commit is contained in:
+45
-112
@@ -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,
|
||||
|
||||
+82
@@ -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: [
|
||||
|
||||
+198
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
@@ -399,6 +399,7 @@ class _TabGridView extends HookConsumerWidget {
|
||||
movingTabIds: result.movingTabIds,
|
||||
previousTabId: result.previousTabId,
|
||||
nextTabId: result.nextTabId,
|
||||
parentChange: result.parentChange,
|
||||
);
|
||||
},
|
||||
childBuilder: (reorderableItemBuilder) {
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+49
@@ -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});
|
||||
}
|
||||
Reference in New Issue
Block a user