hierarchy manipulation
This commit is contained in:
+45
-112
@@ -20,6 +20,7 @@
|
|||||||
import 'package:weblibre/core/logger.dart';
|
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/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/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';
|
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||||
|
|
||||||
class TabViewReorderResult {
|
class TabViewReorderResult {
|
||||||
@@ -27,10 +28,16 @@ class TabViewReorderResult {
|
|||||||
final String? previousTabId;
|
final String? previousTabId;
|
||||||
final String? nextTabId;
|
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({
|
const TabViewReorderResult({
|
||||||
required this.movingTabIds,
|
required this.movingTabIds,
|
||||||
required this.previousTabId,
|
required this.previousTabId,
|
||||||
required this.nextTabId,
|
required this.nextTabId,
|
||||||
|
this.parentChange = const TabParentChange.unchanged(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,21 +115,31 @@ TabViewReorderResult? buildTabViewReorderResult({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final parentScope = _parentScope(movingItem);
|
// Derive the new parent scope purely from the visual drop position. The
|
||||||
final resolvedInsertIndex = _resolveInsertIndexInParentScope(
|
// 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,
|
remaining,
|
||||||
requestedInsertIndex,
|
requestedInsertIndex,
|
||||||
parentScope,
|
|
||||||
parentById,
|
|
||||||
);
|
);
|
||||||
if (resolvedInsertIndex == null) {
|
|
||||||
logger.t(
|
// Cycle guard: the new parent must not be inside the moving subtree.
|
||||||
'reorder snapped: no anchor for parent scope $parentScope — drop '
|
if (newParentScope != null && moveBlockIds.contains(newParentScope)) {
|
||||||
'rejected so the moving tab stays in its original parent',
|
logger.t('reorder refused: new parent scope is inside moving subtree');
|
||||||
);
|
|
||||||
return null;
|
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()
|
final reorderedBlocks = remaining.toList()
|
||||||
..insert(resolvedInsertIndex, movingItem);
|
..insert(resolvedInsertIndex, movingItem);
|
||||||
|
|
||||||
@@ -204,9 +221,26 @@ TabViewReorderResult? buildTabViewReorderResult({
|
|||||||
movingPartitionRootId: _rootIdFor(movingItem.tabId, parentById),
|
movingPartitionRootId: _rootIdFor(movingItem.tabId, parentById),
|
||||||
sortPinnedFirst: sortPinnedFirst,
|
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> _orderedIdsForStorageAnchors(
|
||||||
List<String> orderedTabIds, {
|
List<String> orderedTabIds, {
|
||||||
required TabDirection tabListDirection,
|
required TabDirection tabListDirection,
|
||||||
@@ -237,6 +271,7 @@ List<String> _orderedIdsForStorageAnchors(
|
|||||||
TabViewReorderResult? _resultFromOrderedIds({
|
TabViewReorderResult? _resultFromOrderedIds({
|
||||||
required List<String> movingTabIds,
|
required List<String> movingTabIds,
|
||||||
required List<String> orderedTabIds,
|
required List<String> orderedTabIds,
|
||||||
|
TabParentChange parentChange = const TabParentChange.unchanged(),
|
||||||
}) {
|
}) {
|
||||||
if (movingTabIds.isEmpty) {
|
if (movingTabIds.isEmpty) {
|
||||||
return null;
|
return null;
|
||||||
@@ -260,114 +295,12 @@ TabViewReorderResult? _resultFromOrderedIds({
|
|||||||
nextTabId: lastIndex + 1 < orderedTabIds.length
|
nextTabId: lastIndex + 1 < orderedTabIds.length
|
||||||
? orderedTabIds[lastIndex + 1]
|
? orderedTabIds[lastIndex + 1]
|
||||||
: null,
|
: null,
|
||||||
|
parentChange: parentChange,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _parentScope(TabViewItem item) => item.childItem?.parentId;
|
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, List<TabsWithRootAndDepthResult>> _buildChildrenByParent(
|
||||||
Map<String, TabsWithRootAndDepthResult> rowsById,
|
Map<String, TabsWithRootAndDepthResult> rowsById,
|
||||||
Map<String, String?> parentById,
|
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/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/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/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/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/find_in_page/presentation/controllers/find_in_page.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/pwa/domain/providers.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/providers.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||||
|
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||||
import 'package:weblibre/features/user/domain/repositories/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/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||||
import 'package:weblibre/presentation/hooks/menu_controller.dart';
|
import 'package:weblibre/presentation/hooks/menu_controller.dart';
|
||||||
@@ -74,6 +76,7 @@ class TabMenu extends HookConsumerWidget {
|
|||||||
final bool enablePinTab;
|
final bool enablePinTab;
|
||||||
final bool enableReloadButton;
|
final bool enableReloadButton;
|
||||||
final bool enableNavigationButtons;
|
final bool enableNavigationButtons;
|
||||||
|
final bool enableHierarchy;
|
||||||
|
|
||||||
const TabMenu({
|
const TabMenu({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -94,6 +97,7 @@ class TabMenu extends HookConsumerWidget {
|
|||||||
this.enablePinTab = true,
|
this.enablePinTab = true,
|
||||||
this.enableReloadButton = true,
|
this.enableReloadButton = true,
|
||||||
this.enableNavigationButtons = true,
|
this.enableNavigationButtons = true,
|
||||||
|
this.enableHierarchy = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -560,6 +564,84 @@ class TabMenu extends HookConsumerWidget {
|
|||||||
leadingIcon: const Icon(MdiIcons.folder),
|
leadingIcon: const Icon(MdiIcons.folder),
|
||||||
child: const Text('Container'),
|
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)
|
if (enableShare)
|
||||||
SubmenuButton(
|
SubmenuButton(
|
||||||
menuChildren: [
|
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,
|
movingTabIds: result.movingTabIds,
|
||||||
previousTabId: result.previousTabId,
|
previousTabId: result.previousTabId,
|
||||||
nextTabId: result.nextTabId,
|
nextTabId: result.nextTabId,
|
||||||
|
parentChange: result.parentChange,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
childBuilder: (reorderableItemBuilder) {
|
childBuilder: (reorderableItemBuilder) {
|
||||||
|
|||||||
+1
@@ -458,6 +458,7 @@ class _TabListView extends HookConsumerWidget {
|
|||||||
movingTabIds: result.movingTabIds,
|
movingTabIds: result.movingTabIds,
|
||||||
previousTabId: result.previousTabId,
|
previousTabId: result.previousTabId,
|
||||||
nextTabId: result.nextTabId,
|
nextTabId: result.nextTabId,
|
||||||
|
parentChange: result.parentChange,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
itemBuilder: (context, index) {
|
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/entities/tab_source.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/tab_query_result.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/data/models/tab_query_result.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/tabs/domain/entities/tab_parent_change.dart';
|
||||||
|
|
||||||
class SyncTabsResult {
|
class SyncTabsResult {
|
||||||
final Set<String> deletedIsolationContextIds;
|
final Set<String> deletedIsolationContextIds;
|
||||||
@@ -188,7 +189,14 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
|
|||||||
.getLastChildTabId(containerId.value, parentId.value!)
|
.getLastChildTabId(containerId.value, parentId.value!)
|
||||||
.getSingleOrNull();
|
.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
|
final key = await db.containerDao
|
||||||
.generateOrderKeyAfterTabId(containerId.value, anchorTabId)
|
.generateOrderKeyAfterTabId(containerId.value, anchorTabId)
|
||||||
@@ -433,6 +441,7 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
|
|||||||
required List<String> movingTabIds,
|
required List<String> movingTabIds,
|
||||||
required String? previousTabId,
|
required String? previousTabId,
|
||||||
required String? nextTabId,
|
required String? nextTabId,
|
||||||
|
TabParentChange parentChange = const TabParentChange.unchanged(),
|
||||||
}) {
|
}) {
|
||||||
if (movingTabIds.isEmpty) {
|
if (movingTabIds.isEmpty) {
|
||||||
return Future.value();
|
return Future.value();
|
||||||
@@ -468,11 +477,46 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
|
|||||||
nextRank: nextRank,
|
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) {
|
await batch((batch) {
|
||||||
for (var i = 0; i < movingTabIds.length; i++) {
|
for (var i = 0; i < movingTabIds.length; i++) {
|
||||||
|
final isRoot = i == 0;
|
||||||
batch.update(
|
batch.update(
|
||||||
db.tab,
|
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]),
|
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({
|
List<String> _generateOrderKeysBetween({
|
||||||
required int count,
|
required int count,
|
||||||
required LexoRank? previousRank,
|
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();
|
.watch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Riverpod()
|
||||||
|
Stream<TabData?> watchTabDbData(Ref ref, String tabId) {
|
||||||
|
final db = ref.watch(tabDatabaseProvider);
|
||||||
|
return db.tabDao.getTabDataById(tabId).watchSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
@Riverpod()
|
@Riverpod()
|
||||||
Stream<Map<String, String?>> watchTabDescendants(Ref ref, String tabId) {
|
Stream<Map<String, String?>> watchTabDescendants(Ref ref, String tabId) {
|
||||||
final db = ref.watch(tabDatabaseProvider);
|
final db = ref.watch(tabDatabaseProvider);
|
||||||
|
|||||||
@@ -453,6 +453,76 @@ final class WatchTabsWithRootAndDepthFamily extends $Family
|
|||||||
String toString() => r'watchTabsWithRootAndDepthProvider';
|
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)
|
@ProviderFor(watchTabDescendants)
|
||||||
final watchTabDescendantsProvider = WatchTabDescendantsFamily._();
|
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/entities/tab_mode.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/providers.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';
|
part 'tab.g.dart';
|
||||||
|
|
||||||
@@ -126,6 +127,7 @@ class TabDataRepository extends _$TabDataRepository {
|
|||||||
required List<String> movingTabIds,
|
required List<String> movingTabIds,
|
||||||
required String? previousTabId,
|
required String? previousTabId,
|
||||||
required String? nextTabId,
|
required String? nextTabId,
|
||||||
|
TabParentChange parentChange = const TabParentChange.unchanged(),
|
||||||
}) {
|
}) {
|
||||||
return ref
|
return ref
|
||||||
.read(tabDatabaseProvider)
|
.read(tabDatabaseProvider)
|
||||||
@@ -134,9 +136,37 @@ class TabDataRepository extends _$TabDataRepository {
|
|||||||
movingTabIds: movingTabIds,
|
movingTabIds: movingTabIds,
|
||||||
previousTabId: previousTabId,
|
previousTabId: previousTabId,
|
||||||
nextTabId: nextTabId,
|
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({
|
Future<int> closeAllTabs({
|
||||||
bool includeRegular = true,
|
bool includeRegular = true,
|
||||||
bool includePrivate = 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> {
|
abstract class _$TabDataRepository extends $Notifier<void> {
|
||||||
void build();
|
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