diff --git a/app/lib/features/geckoview/features/bookmarks/domain/entities/bookmark_list_ui_state.dart b/app/lib/features/geckoview/features/bookmarks/domain/entities/bookmark_list_ui_state.dart
new file mode 100644
index 00000000..78d02954
--- /dev/null
+++ b/app/lib/features/geckoview/features/bookmarks/domain/entities/bookmark_list_ui_state.dart
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'package:copy_with_extension/copy_with_extension.dart';
+import 'package:fast_equatable/fast_equatable.dart';
+import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart';
+
+part 'bookmark_list_ui_state.g.dart';
+
+@CopyWith()
+class BookmarkListUiState with FastEquatable {
+ final bool selectionMode;
+ final Set selectedGuids;
+ final BookmarkSortType sortType;
+ final bool foldersOnly;
+
+ BookmarkListUiState({
+ this.selectionMode = false,
+ this.selectedGuids = const {},
+ this.sortType = BookmarkSortType.manual,
+ this.foldersOnly = false,
+ });
+
+ @override
+ List get hashParameters => [
+ selectionMode,
+ selectedGuids,
+ sortType,
+ foldersOnly,
+ ];
+}
diff --git a/app/lib/features/geckoview/features/bookmarks/domain/entities/bookmark_list_ui_state.g.dart b/app/lib/features/geckoview/features/bookmarks/domain/entities/bookmark_list_ui_state.g.dart
new file mode 100644
index 00000000..64059268
--- /dev/null
+++ b/app/lib/features/geckoview/features/bookmarks/domain/entities/bookmark_list_ui_state.g.dart
@@ -0,0 +1,100 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'bookmark_list_ui_state.dart';
+
+// **************************************************************************
+// CopyWithGenerator
+// **************************************************************************
+
+abstract class _$BookmarkListUiStateCWProxy {
+ BookmarkListUiState selectionMode(bool selectionMode);
+
+ BookmarkListUiState selectedGuids(Set selectedGuids);
+
+ BookmarkListUiState sortType(BookmarkSortType sortType);
+
+ BookmarkListUiState foldersOnly(bool foldersOnly);
+
+ /// Creates a new instance with the provided field values.
+ /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `BookmarkListUiState(...).copyWith.fieldName(value)`.
+ ///
+ /// Example:
+ /// ```dart
+ /// BookmarkListUiState(...).copyWith(id: 12, name: "My name")
+ /// ```
+ BookmarkListUiState call({
+ bool selectionMode,
+ Set selectedGuids,
+ BookmarkSortType sortType,
+ bool foldersOnly,
+ });
+}
+
+/// Callable proxy for `copyWith` functionality.
+/// Use as `instanceOfBookmarkListUiState.copyWith(...)` or call `instanceOfBookmarkListUiState.copyWith.fieldName(value)` for a single field.
+class _$BookmarkListUiStateCWProxyImpl implements _$BookmarkListUiStateCWProxy {
+ const _$BookmarkListUiStateCWProxyImpl(this._value);
+
+ final BookmarkListUiState _value;
+
+ @override
+ BookmarkListUiState selectionMode(bool selectionMode) =>
+ call(selectionMode: selectionMode);
+
+ @override
+ BookmarkListUiState selectedGuids(Set selectedGuids) =>
+ call(selectedGuids: selectedGuids);
+
+ @override
+ BookmarkListUiState sortType(BookmarkSortType sortType) =>
+ call(sortType: sortType);
+
+ @override
+ BookmarkListUiState foldersOnly(bool foldersOnly) =>
+ call(foldersOnly: foldersOnly);
+
+ @override
+ /// Creates a new instance with the provided field values.
+ /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `BookmarkListUiState(...).copyWith.fieldName(value)`.
+ ///
+ /// Example:
+ /// ```dart
+ /// BookmarkListUiState(...).copyWith(id: 12, name: "My name")
+ /// ```
+ BookmarkListUiState call({
+ Object? selectionMode = const $CopyWithPlaceholder(),
+ Object? selectedGuids = const $CopyWithPlaceholder(),
+ Object? sortType = const $CopyWithPlaceholder(),
+ Object? foldersOnly = const $CopyWithPlaceholder(),
+ }) {
+ return BookmarkListUiState(
+ selectionMode:
+ selectionMode == const $CopyWithPlaceholder() || selectionMode == null
+ ? _value.selectionMode
+ // ignore: cast_nullable_to_non_nullable
+ : selectionMode as bool,
+ selectedGuids:
+ selectedGuids == const $CopyWithPlaceholder() || selectedGuids == null
+ ? _value.selectedGuids
+ // ignore: cast_nullable_to_non_nullable
+ : selectedGuids as Set,
+ sortType: sortType == const $CopyWithPlaceholder() || sortType == null
+ ? _value.sortType
+ // ignore: cast_nullable_to_non_nullable
+ : sortType as BookmarkSortType,
+ foldersOnly:
+ foldersOnly == const $CopyWithPlaceholder() || foldersOnly == null
+ ? _value.foldersOnly
+ // ignore: cast_nullable_to_non_nullable
+ : foldersOnly as bool,
+ );
+ }
+}
+
+extension $BookmarkListUiStateCopyWith on BookmarkListUiState {
+ /// Returns a callable class used to build a new instance with modified fields.
+ /// Example: `instanceOfBookmarkListUiState.copyWith(...)` or `instanceOfBookmarkListUiState.copyWith.fieldName(...)`.
+ // ignore: library_private_types_in_public_api
+ _$BookmarkListUiStateCWProxy get copyWith =>
+ _$BookmarkListUiStateCWProxyImpl(this);
+}
diff --git a/app/lib/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart b/app/lib/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart
new file mode 100644
index 00000000..9fe12940
--- /dev/null
+++ b/app/lib/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
+
+enum BookmarkSortType {
+ manual('Default'),
+ titleAsc('Title A-Z'),
+ titleDesc('Title Z-A'),
+ urlAsc('URL A-Z'),
+ dateAddedDesc('Newest First');
+
+ final String label;
+
+ const BookmarkSortType(this.label);
+}
+
+int compareBookmarkItems(
+ BookmarkItem a,
+ BookmarkItem b,
+ BookmarkSortType sort,
+) {
+ return switch (sort) {
+ BookmarkSortType.manual => 0,
+ BookmarkSortType.titleAsc => a.title.toLowerCase().compareTo(
+ b.title.toLowerCase(),
+ ),
+ BookmarkSortType.titleDesc => b.title.toLowerCase().compareTo(
+ a.title.toLowerCase(),
+ ),
+ BookmarkSortType.urlAsc => _compareByUrl(a, b),
+ BookmarkSortType.dateAddedDesc => b.dateAdded.compareTo(a.dateAdded),
+ };
+}
+
+int _compareByUrl(BookmarkItem a, BookmarkItem b) {
+ final aUrl = a is BookmarkEntry ? a.url.toString() : a.title.toLowerCase();
+ final bUrl = b is BookmarkEntry ? b.url.toString() : b.title.toLowerCase();
+ return aUrl.compareTo(bUrl);
+}
diff --git a/app/lib/features/geckoview/features/bookmarks/domain/providers/bookmark_list_ui_state.dart b/app/lib/features/geckoview/features/bookmarks/domain/providers/bookmark_list_ui_state.dart
new file mode 100644
index 00000000..3b00f113
--- /dev/null
+++ b/app/lib/features/geckoview/features/bookmarks/domain/providers/bookmark_list_ui_state.dart
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'package:riverpod_annotation/riverpod_annotation.dart';
+import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_list_ui_state.dart';
+import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart';
+
+part 'bookmark_list_ui_state.g.dart';
+
+@Riverpod()
+class BookmarkListUiStateNotifier extends _$BookmarkListUiStateNotifier {
+ @override
+ BookmarkListUiState build() => BookmarkListUiState();
+
+ void enterSelectionMode({String? initialGuid}) {
+ state = state.copyWith(
+ selectionMode: true,
+ selectedGuids: initialGuid != null ? {initialGuid} : {},
+ );
+ }
+
+ void exitSelectionMode() {
+ state = state.copyWith(selectionMode: false, selectedGuids: {});
+ }
+
+ void toggleSelection(String guid) {
+ final updated = Set.from(state.selectedGuids);
+ if (updated.contains(guid)) {
+ updated.remove(guid);
+ } else {
+ updated.add(guid);
+ }
+ if (updated.isEmpty) {
+ exitSelectionMode();
+ } else {
+ state = state.copyWith(selectedGuids: updated);
+ }
+ }
+
+ void selectAll(Iterable guids) {
+ state = state.copyWith(
+ selectionMode: true,
+ selectedGuids: Set.from(guids),
+ );
+ }
+
+ void clearSelection() {
+ exitSelectionMode();
+ }
+
+ void setSortType(BookmarkSortType sortType) {
+ state = state.copyWith(sortType: sortType);
+ }
+
+ void toggleFoldersOnly() {
+ state = state.copyWith(foldersOnly: !state.foldersOnly);
+ }
+}
diff --git a/app/lib/features/geckoview/features/bookmarks/domain/providers/bookmark_list_ui_state.g.dart b/app/lib/features/geckoview/features/bookmarks/domain/providers/bookmark_list_ui_state.g.dart
new file mode 100644
index 00000000..b82ee652
--- /dev/null
+++ b/app/lib/features/geckoview/features/bookmarks/domain/providers/bookmark_list_ui_state.g.dart
@@ -0,0 +1,65 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'bookmark_list_ui_state.dart';
+
+// **************************************************************************
+// RiverpodGenerator
+// **************************************************************************
+
+// GENERATED CODE - DO NOT MODIFY BY HAND
+// ignore_for_file: type=lint, type=warning
+
+@ProviderFor(BookmarkListUiStateNotifier)
+final bookmarkListUiStateProvider = BookmarkListUiStateNotifierProvider._();
+
+final class BookmarkListUiStateNotifierProvider
+ extends
+ $NotifierProvider {
+ BookmarkListUiStateNotifierProvider._()
+ : super(
+ from: null,
+ argument: null,
+ retry: null,
+ name: r'bookmarkListUiStateProvider',
+ isAutoDispose: true,
+ dependencies: null,
+ $allTransitiveDependencies: null,
+ );
+
+ @override
+ String debugGetCreateSourceHash() => _$bookmarkListUiStateNotifierHash();
+
+ @$internal
+ @override
+ BookmarkListUiStateNotifier create() => BookmarkListUiStateNotifier();
+
+ /// {@macro riverpod.override_with_value}
+ Override overrideWithValue(BookmarkListUiState value) {
+ return $ProviderOverride(
+ origin: this,
+ providerOverride: $SyncValueProvider(value),
+ );
+ }
+}
+
+String _$bookmarkListUiStateNotifierHash() =>
+ r'7c764fc2f1deb178063ca95764775ca237471f9f';
+
+abstract class _$BookmarkListUiStateNotifier
+ extends $Notifier {
+ BookmarkListUiState build();
+ @$mustCallSuper
+ @override
+ void runBuild() {
+ final ref = this.ref as $Ref;
+ final element =
+ ref.element
+ as $ClassProviderElement<
+ AnyNotifier,
+ BookmarkListUiState,
+ Object?,
+ Object?
+ >;
+ element.handleCreate(ref, build);
+ }
+}
diff --git a/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart b/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart
index 31c488db..5f14828c 100644
--- a/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart
+++ b/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart
@@ -20,6 +20,7 @@
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:nullability/nullability.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
+import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_html_utils.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_json_utils.dart';
@@ -36,16 +37,18 @@ class BookmarksRepository extends _$BookmarksRepository {
required String parentGuid,
required Uri url,
required String title,
+ int? position,
}) async {
- await _service.addItem(parentGuid, url, title, null);
+ await _service.addItem(parentGuid, url, title, position);
ref.invalidateSelf();
}
Future addFolder({
required String parentGuid,
required String title,
+ int? position,
}) async {
- await _service.addFolder(parentGuid, title, null);
+ await _service.addFolder(parentGuid, title, position);
ref.invalidateSelf();
}
@@ -54,10 +57,16 @@ class BookmarksRepository extends _$BookmarksRepository {
String? title,
Uri? url,
String? parentGuid,
+ int? position,
}) async {
await _service.updateNode(
guid,
- BookmarkInfo(title: title, url: url?.toString(), parentGuid: parentGuid),
+ BookmarkInfo(
+ title: title,
+ url: url?.toString(),
+ parentGuid: parentGuid,
+ position: position,
+ ),
);
ref.invalidateSelf();
}
@@ -66,10 +75,11 @@ class BookmarksRepository extends _$BookmarksRepository {
required String guid,
String? title,
String? parentGuid,
+ int? position,
}) async {
await _service.updateNode(
guid,
- BookmarkInfo(title: title, parentGuid: parentGuid),
+ BookmarkInfo(title: title, parentGuid: parentGuid, position: position),
);
ref.invalidateSelf();
}
@@ -79,6 +89,78 @@ class BookmarksRepository extends _$BookmarksRepository {
ref.invalidateSelf();
}
+ Future moveMany({
+ required Iterable items,
+ required String targetParentGuid,
+ }) async {
+ for (final item in items) {
+ if (bookmarkRootIds.contains(item.guid)) {
+ logger.w('Skipping move of root folder: ${item.guid}');
+ continue;
+ }
+ if (item.parentGuid == targetParentGuid) continue;
+ await _service.updateNode(
+ item.guid,
+ BookmarkInfo(parentGuid: targetParentGuid),
+ );
+ }
+ ref.invalidateSelf();
+ }
+
+ Future deleteMany(Iterable guids) async {
+ for (final guid in guids) {
+ if (bookmarkRootIds.contains(guid)) {
+ logger.w('Skipping delete of root folder: $guid');
+ continue;
+ }
+ await _service.deleteNode(guid);
+ }
+ ref.invalidateSelf();
+ }
+
+ Future flattenFolder({required BookmarkFolder folder}) async {
+ if (folder.parentGuid == null || bookmarkRootIds.contains(folder.guid)) {
+ logger.w('Cannot flatten root or parentless folder: ${folder.guid}');
+ return;
+ }
+ // Fetch the full folder tree from storage to avoid operating on a
+ // filtered subset (e.g. when search is active), which would silently
+ // delete children that were not moved.
+ final fullNode = await _service.getTree(folder.guid);
+ final children = fullNode?.children;
+ if (children != null) {
+ for (final child in children) {
+ await _service.updateNode(
+ child.guid,
+ BookmarkInfo(parentGuid: folder.parentGuid),
+ );
+ }
+ }
+ await _service.deleteNode(folder.guid);
+ ref.invalidateSelf();
+ }
+
+ /// Returns the GUIDs of all descendant folders of [guid] by fetching the
+ /// full subtree from storage. This is safe to call even when the UI tree is
+ /// filtered (e.g. during search), unlike the pure-utility
+ /// [collectDescendantFolderGuids] which only walks the in-memory tree.
+ Future> getDescendantFolderGuids(String guid) async {
+ final node = await _service.getTree(guid, recursive: true);
+ if (node == null) return const {};
+ final result = {};
+ void collect(BookmarkNode n) {
+ for (final child in n.children ?? const []) {
+ if (child.type == BookmarkNodeType.folder) {
+ result.add(child.guid);
+ collect(child);
+ }
+ }
+ }
+
+ collect(node);
+ return result;
+ }
+
Future eraseEverything(BookmarkRoot root) async {
await _service.eraseEverything(root);
ref.invalidateSelf();
diff --git a/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.g.dart b/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.g.dart
index 458db048..f60cfda6 100644
--- a/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.g.dart
+++ b/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.g.dart
@@ -34,7 +34,7 @@ final class BookmarksRepositoryProvider
}
String _$bookmarksRepositoryHash() =>
- r'c53414612bf1d1da824e1150ca4eca7bb7c3bec7';
+ r'2169d5b354c4a22192096451c96ab1490cf55ab4';
abstract class _$BookmarksRepository extends $AsyncNotifier {
FutureOr build();
diff --git a/app/lib/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart b/app/lib/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart
new file mode 100644
index 00000000..238babe9
--- /dev/null
+++ b/app/lib/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart
@@ -0,0 +1,142 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
+import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
+import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart';
+
+/// Recursively sorts a bookmark tree by the given sort type.
+/// Root-level built-in folders are kept in their canonical order.
+BookmarkItem sortBookmarkTree(
+ BookmarkItem item,
+ BookmarkSortType sortType, {
+ bool isRoot = false,
+}) {
+ if (sortType == BookmarkSortType.manual) return item;
+
+ if (item is BookmarkFolder && item.children != null) {
+ final sortedChildren = item.children!.map((child) {
+ return sortBookmarkTree(child, sortType);
+ }).toList();
+
+ // At root level, keep built-in root folders pinned in original order
+ if (isRoot) {
+ final rootFolders = [];
+ final nonRootItems = [];
+ for (final child in sortedChildren) {
+ if (bookmarkRootIds.contains(child.guid)) {
+ rootFolders.add(child);
+ } else {
+ nonRootItems.add(child);
+ }
+ }
+ nonRootItems.sort((a, b) => compareBookmarkItems(a, b, sortType));
+ return BookmarkFolder(
+ guid: item.guid,
+ parentGuid: item.parentGuid,
+ title: item.title,
+ position: item.position,
+ dateAdded: item.dateAdded,
+ children: [...rootFolders, ...nonRootItems],
+ );
+ }
+
+ sortedChildren.sort((a, b) => compareBookmarkItems(a, b, sortType));
+ return BookmarkFolder(
+ guid: item.guid,
+ parentGuid: item.parentGuid,
+ title: item.title,
+ position: item.position,
+ dateAdded: item.dateAdded,
+ children: sortedChildren,
+ );
+ }
+
+ return item;
+}
+
+/// Collects all descendant folder GUIDs from a folder (not including the folder itself).
+Set collectDescendantFolderGuids(BookmarkFolder folder) {
+ final result = {};
+ if (folder.children != null) {
+ for (final child in folder.children!) {
+ if (child is BookmarkFolder) {
+ result.add(child.guid);
+ result.addAll(collectDescendantFolderGuids(child));
+ }
+ }
+ }
+ return result;
+}
+
+/// Resolves BookmarkItems from a tree by their GUIDs.
+List resolveSelectedItems(BookmarkItem root, Set guids) {
+ final result = [];
+ _collectByGuids(root, guids, result);
+ return result;
+}
+
+void _collectByGuids(
+ BookmarkItem item,
+ Set guids,
+ List result,
+) {
+ if (guids.contains(item.guid)) {
+ result.add(item);
+ }
+ if (item is BookmarkFolder && item.children != null) {
+ for (final child in item.children!) {
+ _collectByGuids(child, guids, result);
+ }
+ }
+}
+
+/// Whether a folder can be flattened (non-root, has a parent, has children).
+bool canFlattenFolder(BookmarkFolder folder) {
+ return folder.parentGuid != null &&
+ !bookmarkRootIds.contains(folder.guid) &&
+ folder.children != null &&
+ folder.children!.isNotEmpty;
+}
+
+/// Normalizes a selection set: removes items that are descendants of selected folders.
+/// This prevents double-applying moves when both a folder and its children are selected.
+Set normalizeSelection(BookmarkItem root, Set selectedGuids) {
+ final items = resolveSelectedItems(root, selectedGuids);
+ final folderGuidsToRemove = {};
+
+ for (final item in items) {
+ if (item is BookmarkFolder) {
+ _collectAllDescendantGuids(item, folderGuidsToRemove);
+ }
+ }
+
+ return selectedGuids.difference(folderGuidsToRemove);
+}
+
+void _collectAllDescendantGuids(BookmarkFolder folder, Set result) {
+ if (folder.children != null) {
+ for (final child in folder.children!) {
+ result.add(child.guid);
+ if (child is BookmarkFolder) {
+ _collectAllDescendantGuids(child, result);
+ }
+ }
+ }
+}
diff --git a/app/lib/features/geckoview/features/bookmarks/presentation/dialogs/select_bookmark_folder_dialog.dart b/app/lib/features/geckoview/features/bookmarks/presentation/dialogs/select_bookmark_folder_dialog.dart
new file mode 100644
index 00000000..5ff03ec3
--- /dev/null
+++ b/app/lib/features/geckoview/features/bookmarks/presentation/dialogs/select_bookmark_folder_dialog.dart
@@ -0,0 +1,102 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
+import 'package:go_router/go_router.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:weblibre/features/geckoview/features/bookmarks/presentation/widgets/folder_tree_picker.dart';
+
+/// Shows a bottom sheet for selecting a bookmark folder destination (for move operations).
+///
+/// Returns the selected folder GUID, or null if cancelled.
+Future showSelectBookmarkFolderDialog(
+ BuildContext context, {
+ Set excludeFolderGuids = const {},
+ String? initialFolderGuid,
+}) {
+ return showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ builder: (context) => _SelectBookmarkFolderSheet(
+ excludeFolderGuids: excludeFolderGuids,
+ initialFolderGuid: initialFolderGuid,
+ ),
+ );
+}
+
+class _SelectBookmarkFolderSheet extends HookConsumerWidget {
+ final Set excludeFolderGuids;
+ final String? initialFolderGuid;
+
+ const _SelectBookmarkFolderSheet({
+ required this.excludeFolderGuids,
+ this.initialFolderGuid,
+ });
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final selectedGuid = useState(initialFolderGuid ?? BookmarkRoot.mobile.id);
+
+ return SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Text(
+ 'Move to Folder',
+ style: Theme.of(context).textTheme.titleLarge,
+ ),
+ const SizedBox(height: 16),
+ ConstrainedBox(
+ constraints: BoxConstraints(
+ maxHeight: MediaQuery.of(context).size.height * 0.5,
+ ),
+ child: SingleChildScrollView(
+ child: FolderTreePicker(
+ selectedFolderGuid: selectedGuid,
+ excludeFolderGuids: excludeFolderGuids,
+ entryGuid: BookmarkRoot.root.id,
+ ),
+ ),
+ ),
+ const SizedBox(height: 16),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ TextButton(
+ onPressed: () => context.pop(),
+ child: const Text('Cancel'),
+ ),
+ const SizedBox(width: 8),
+ FilledButton(
+ onPressed: () => context.pop(selectedGuid.value),
+ child: const Text('Move'),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_entry_edit.dart b/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_entry_edit.dart
index ea140b80..28c567dd 100644
--- a/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_entry_edit.dart
+++ b/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_entry_edit.dart
@@ -57,6 +57,8 @@ class BookmarkEntryEditScreen extends HookConsumerWidget {
BookmarkRoot.mobile.id,
);
+ final addToTop = useState(false);
+
return Scaffold(
appBar: AppBar(
title: (exisitingEntry != null)
@@ -104,6 +106,7 @@ class BookmarkEntryEditScreen extends HookConsumerWidget {
parentGuid: parentGuid.value,
title: nameTextController.text,
url: newUrl,
+ position: addToTop.value ? 0 : null,
);
if (context.mounted) {
@@ -157,6 +160,15 @@ class BookmarkEntryEditScreen extends HookConsumerWidget {
selectedFolderGuid: parentGuid,
entryGuid: BookmarkRoot.root.id,
),
+ if (exisitingEntry == null) ...[
+ const SizedBox(height: 8),
+ SwitchListTile(
+ contentPadding: EdgeInsets.zero,
+ title: const Text('Add to top'),
+ value: addToTop.value,
+ onChanged: (value) => addToTop.value = value,
+ ),
+ ],
const SizedBox(height: 16),
if (exisitingEntry != null)
SizedBox(
diff --git a/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_folder_edit.dart b/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_folder_edit.dart
index 398c41d9..155c5394 100644
--- a/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_folder_edit.dart
+++ b/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_folder_edit.dart
@@ -47,6 +47,8 @@ class BookmarkFolderEditScreen extends HookConsumerWidget {
parentGuid ?? folder?.parentGuid ?? BookmarkRoot.mobile.id,
);
+ final addToTop = useState(false);
+
// Check if this is a bookmark root folder (these cannot be moved)
final isBookmarkRoot =
folder != null && bookmarkRootIds.contains(folder!.guid);
@@ -84,6 +86,7 @@ class BookmarkFolderEditScreen extends HookConsumerWidget {
.addFolder(
parentGuid: currentParentGuid.value,
title: nameTextController.text,
+ position: addToTop.value ? 0 : null,
);
if (context.mounted) {
@@ -114,9 +117,20 @@ class BookmarkFolderEditScreen extends HookConsumerWidget {
if (!isBookmarkRoot) ...[
FolderTreePicker(
selectedFolderGuid: currentParentGuid,
- excludeFolderGuid: folder?.guid,
+ excludeFolderGuids: folder != null
+ ? {folder!.guid}
+ : const {},
entryGuid: BookmarkRoot.root.id,
),
+ if (folder == null) ...[
+ const SizedBox(height: 8),
+ SwitchListTile(
+ contentPadding: EdgeInsets.zero,
+ title: const Text('Add to top'),
+ value: addToTop.value,
+ onChanged: (value) => addToTop.value = value,
+ ),
+ ],
const SizedBox(height: 16),
],
if (folder != null)
diff --git a/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_list.dart b/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_list.dart
index 65ceca5e..cbc83134 100644
--- a/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_list.dart
+++ b/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_list.dart
@@ -28,13 +28,24 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:nullability/nullability.dart';
+import 'package:share_plus/share_plus.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/core/routing/routes.dart';
+import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
+import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
+import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_list_ui_state.dart';
+import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart';
+import 'package:weblibre/features/geckoview/features/bookmarks/domain/providers/bookmark_list_ui_state.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/providers/bookmarks.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
+import 'package:weblibre/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart';
+import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/delete_bookmark_dialog.dart';
+import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/delete_folder_dialog.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/import_bookmarks_dialog.dart';
+import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/select_bookmark_folder_dialog.dart';
+import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
+import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/hooks/menu_controller.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
@@ -48,8 +59,16 @@ class BookmarkListScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final treeKey = useMemoized(() => GlobalKey());
+ final treeController =
+ useState>?>(
+ null,
+ );
+ // Tracks which folder GUIDs are expanded, so expansion state survives
+ // tree rebuilds caused by sort type changes.
+ final expandedGuids = useRef({});
final hideEmptyRoots = useState(true);
+ final uiState = ref.watch(bookmarkListUiStateProvider);
+ final uiStateNotifier = ref.read(bookmarkListUiStateProvider.notifier);
final bookmarkList = ref.watch(
seamlessBookmarksProvider(
entryGuid,
@@ -78,297 +97,901 @@ class BookmarkListScreen extends HookConsumerWidget {
}
});
- return Scaffold(
- appBar: AppBar(
- title: textFilterEnabled.value
- ? TextField(
- controller: textFilterController,
- decoration: InputDecoration(
- contentPadding: const EdgeInsets.only(top: 12),
- border: InputBorder.none,
- hintText: 'Filter bookmarks...',
- floatingLabelBehavior: FloatingLabelBehavior.always,
- suffixIcon: IconButton(
- onPressed: () {
- if (textFilterController.text.isNotEmpty) {
- textFilterController.clear();
- } else {
- textFilterEnabled.value = false;
- }
- },
- icon: const Icon(Icons.clear),
- ),
- ),
- )
- : const Text('Bookmarks'),
- actions: [
- if (!textFilterEnabled.value)
- IconButton(
- onPressed: () {
- textFilterEnabled.value = !textFilterEnabled.value;
- },
- icon: const Icon(Icons.search),
- ),
- MenuAnchor(
- menuChildren: [
- MenuItemButton(
- leadingIcon: const Icon(MdiIcons.expandAll),
- child: const Text('Expand All Folders'),
- onPressed: () {
- treeKey.currentState?.controller.mapNotNull((controller) {
- controller.expandAllChildren(
- controller.tree,
- recursive: true,
- );
- });
- },
+ return PopScope(
+ canPop: !uiState.selectionMode,
+ onPopInvokedWithResult: (didPop, _) {
+ if (!didPop && uiState.selectionMode) {
+ uiStateNotifier.exitSelectionMode();
+ }
+ },
+ child: Scaffold(
+ appBar: uiState.selectionMode
+ ? _buildSelectionAppBar(context, ref, uiState, uiStateNotifier)
+ : _buildNormalAppBar(
+ context,
+ ref,
+ treeController,
+ expandedGuids,
+ hideEmptyRoots,
+ textFilterEnabled,
+ textFilterController,
+ uiStateNotifier,
+ uiState,
),
- if (entryGuid == BookmarkRoot.root.id)
- MenuItemButton(
- leadingIcon: Icon(
- hideEmptyRoots.value ? MdiIcons.eyeOff : MdiIcons.eye,
- ),
- child: Text(
- hideEmptyRoots.value
- ? 'Show Empty Root Folders'
- : 'Hide Empty Root Folders',
- ),
- onPressed: () {
- hideEmptyRoots.value = !hideEmptyRoots.value;
+ body: SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.only(left: 12.0),
+ child: bookmarkList.when(
+ skipLoadingOnReload: true,
+ data: (list) {
+ final sortedList = list != null
+ ? sortBookmarkTree(
+ list,
+ uiState.sortType,
+ isRoot: entryGuid == BookmarkRoot.root.id,
+ )
+ : null;
+
+ TreeNode addChildren(
+ TreeNode? parent,
+ BookmarkItem item,
+ ) {
+ if (uiState.foldersOnly && item is BookmarkEntry) {
+ return parent ?? TreeNode.root();
+ }
+
+ final node = TreeNode(
+ key: item.guid,
+ data: item,
+ parent: parent,
+ );
+ final targetNode = (parent?..add(node)) ?? node;
+
+ if (item is BookmarkFolder && item.children != null) {
+ for (final child in item.children!) {
+ addChildren(node, child);
+ }
+ }
+
+ return targetNode;
+ }
+
+ final root = (sortedList != null)
+ ? addChildren(null, sortedList)
+ : TreeNode.root();
+
+ return TreeView.simple(
+ key: ValueKey((uiState.sortType, uiState.foldersOnly)),
+ tree: root,
+ showRootNode: entryGuid != BookmarkRoot.root.id,
+ onTreeReady: (controller) {
+ treeController.value = controller;
+ if (expandedGuids.value.isNotEmpty) {
+ _restoreExpansion(controller, root, expandedGuids.value);
+ } else {
+ controller.expandAllChildren(root, recursive: true);
+ }
+ },
+ expansionIndicatorBuilder: (context, tree) =>
+ ChevronIndicator.upDown(
+ tree: tree,
+ padding: const EdgeInsets.symmetric(
+ vertical: 16.0,
+ horizontal: 12.0,
+ ),
+ ),
+ builder: (context, item) {
+ final BookmarkItem? data = item.data;
+ final isSelected = uiState.selectedGuids.contains(
+ data?.guid,
+ );
+ final bool isLeaf = item.isLeaf;
+ final bool isExpanded = item.isExpanded;
+
+ return switch (data) {
+ final BookmarkEntry bookmark => _buildEntryTile(
+ context,
+ ref,
+ bookmark,
+ uiState,
+ uiStateNotifier,
+ isSelected,
+ sortedList,
+ ),
+ final BookmarkFolder folder => _buildFolderTile(
+ context,
+ ref,
+ folder,
+ isLeaf: isLeaf,
+ isExpanded: isExpanded,
+ uiState: uiState,
+ uiStateNotifier: uiStateNotifier,
+ isSelected: isSelected,
+ rootItem: sortedList,
+ ),
+ null => const Center(child: Text('Empty')),
+ };
+ },
+ );
+ },
+ error: (error, stackTrace) => Center(
+ child: FailureWidget(
+ title: 'Failed to load Bookmarks',
+ exception: error,
+ onRetry: () {
+ // ignore: unused_result
+ ref.refresh(bookmarksProvider(entryGuid));
},
),
- SubmenuButton(
- leadingIcon: const Icon(MdiIcons.import),
- menuChildren: [
- MenuItemButton(
- leadingIcon: const Icon(MdiIcons.codeJson),
- child: const Text('JSON'),
- onPressed: () => _handleImport(context, ref, 'json'),
- ),
- MenuItemButton(
- leadingIcon: const Icon(MdiIcons.xml),
- child: const Text('HTML'),
- onPressed: () => _handleImport(context, ref, 'html'),
- ),
- ],
- child: const Text('Import'),
),
- SubmenuButton(
- leadingIcon: const Icon(MdiIcons.export),
- menuChildren: [
- MenuItemButton(
- leadingIcon: const Icon(MdiIcons.codeJson),
- child: const Text('JSON'),
- onPressed: () => _handleExport(context, ref, 'json'),
- ),
- MenuItemButton(
- leadingIcon: const Icon(MdiIcons.xml),
- child: const Text('HTML'),
- onPressed: () => _handleExport(context, ref, 'html'),
- ),
- ],
- child: const Text('Export'),
- ),
- ],
- builder: (context, controller, child) => IconButton(
- onPressed: () {
- if (controller.isOpen) {
- controller.close();
- } else {
- controller.open();
- }
- },
- icon: const Icon(MdiIcons.dotsVertical),
+ loading: () => const Center(child: CircularProgressIndicator()),
),
),
- ],
- ),
- body: SafeArea(
- child: Padding(
- padding: const EdgeInsets.only(left: 12.0),
- child: bookmarkList.when(
- skipLoadingOnReload: true,
- data: (list) {
- TreeNode addChildren(
- TreeNode? parent,
- BookmarkItem item,
- ) {
- final node = TreeNode(
- key: item.guid,
- data: item,
- parent: parent,
- );
- final targetNode = (parent?..add(node)) ?? node;
-
- if (item is BookmarkFolder && item.children != null) {
- for (final child in item.children!) {
- addChildren(node, child);
- }
- }
-
- return targetNode;
- }
-
- final root = (list != null)
- ? addChildren(null, list)
- : TreeNode.root();
-
- return TreeView.simple(
- key: treeKey,
- tree: root,
- showRootNode: entryGuid != BookmarkRoot.root.id,
- onTreeReady: (controller) {
- if (textFilterEnabled.value) {
- controller.expandAllChildren(root, recursive: true);
- } else {
- controller.expandNode(root);
- }
- },
- expansionIndicatorBuilder: (context, tree) =>
- ChevronIndicator.upDown(
- tree: tree,
- padding: const EdgeInsets.symmetric(
- vertical: 16.0,
- horizontal: 12.0,
- ),
- ),
- builder: (context, item) {
- return switch (item.data) {
- final BookmarkEntry bookmark => ListTile(
- key: ValueKey(bookmark.guid),
- contentPadding: EdgeInsets.zero,
- leading: UrlIcon([bookmark.url], iconSize: 34.0),
- trailing: IconButton(
- icon: const Icon(Icons.edit),
- onPressed: () async {
- await BookmarkEntryEditRoute(
- bookmarkEntry: jsonEncode(bookmark.toJson()),
- ).push(context);
- },
- ),
- title: Text(
- bookmark.title,
- maxLines: 3,
- overflow: TextOverflow.ellipsis,
- ),
- subtitle: UriBreadcrumb(uri: bookmark.url),
- onTap: () async {
- final result = await OpenSharedContentRoute(
- sharedUrl: bookmark.url.toString(),
- ).push(context);
-
- if (result == true) {
- if (context.mounted) {
- const BrowserRoute().go(context);
- }
- }
- },
- ),
- final BookmarkFolder folder => Padding(
- key: ValueKey(folder.guid),
- padding: (item.isLeaf)
- ? const EdgeInsets.only(right: 4.0)
- : const EdgeInsets.only(right: 42.0),
- child: HookBuilder(
- builder: (context) {
- final controller = useMenuController();
-
- return ListTile(
- contentPadding: EdgeInsets.zero,
- leading: (item.isExpanded)
- ? const Icon(MdiIcons.folderOpen)
- : const Icon(MdiIcons.folder),
- title: Text(folder.title),
- trailing: MenuAnchor(
- controller: controller,
- builder: (context, controller, child) => InkWell(
- onTap: () {
- if (controller.isOpen) {
- controller.close();
- } else {
- controller.open();
- }
- },
- child: const Padding(
- padding: EdgeInsets.symmetric(
- horizontal: 8.0,
- vertical: 15.0,
- ),
- child: Icon(MdiIcons.dotsVertical),
- ),
- ),
- menuChildren: [
- if (!bookmarkRootIds.contains(folder.guid))
- MenuItemButton(
- leadingIcon: const Icon(
- MdiIcons.folderEdit,
- ),
- child: const Text('Edit'),
- onPressed: () async {
- await BookmarkFolderEditRoute(
- folder: jsonEncode(folder.toJson()),
- ).push(context);
- },
- ),
- MenuItemButton(
- leadingIcon: const Icon(MdiIcons.folderPlus),
- child: const Text('Add Subfolder'),
- onPressed: () async {
- await BookmarkFolderAddRoute(
- parentGuid: folder.guid,
- ).push(context);
- },
- ),
- MenuItemButton(
- leadingIcon: const Icon(
- MdiIcons.bookmarkPlus,
- ),
- child: const Text('Add Bookmark'),
- onPressed: () async {
- await BookmarkEntryAddRoute(
- bookmarkInfo: jsonEncode(
- BookmarkInfo(
- parentGuid: folder.guid,
- ).encode(),
- ),
- ).push(context);
- },
- ),
- ],
- ),
- onTap: () async {
- if (folder.guid != entryGuid) {
- await BookmarkListRoute(
- entryGuid: folder.guid,
- ).push(context);
- }
- },
- );
- },
- ),
- ),
- null => const Center(child: Text('Empty')),
- };
- },
- );
- },
- error: (error, stackTrace) => Center(
- child: FailureWidget(
- title: 'Failed to load Bookmarks',
- exception: error,
- onRetry: () {
- // ignore: unused_result
- ref.refresh(bookmarksProvider(entryGuid));
- },
- ),
- ),
- loading: () => const Center(child: CircularProgressIndicator()),
- ),
),
),
);
}
+ // -- App Bars --
+
+ PreferredSizeWidget _buildSelectionAppBar(
+ BuildContext context,
+ WidgetRef ref,
+ BookmarkListUiState uiState,
+ BookmarkListUiStateNotifier uiStateNotifier,
+ ) {
+ final count = uiState.selectedGuids.length;
+ return AppBar(
+ leading: IconButton(
+ icon: const Icon(Icons.close),
+ onPressed: () => uiStateNotifier.exitSelectionMode(),
+ ),
+ title: Text('$count selected'),
+ actions: [
+ IconButton(
+ icon: const Icon(MdiIcons.tabPlus),
+ tooltip: 'Open in background',
+ onPressed: count > 0
+ ? () => _bulkOpenInBackground(context, ref, uiState)
+ : null,
+ ),
+ IconButton(
+ icon: const Icon(MdiIcons.folderMove),
+ tooltip: 'Move selected',
+ onPressed: count > 0 ? () => _bulkMove(context, ref, uiState) : null,
+ ),
+ IconButton(
+ icon: const Icon(MdiIcons.delete),
+ tooltip: 'Delete selected',
+ onPressed: count > 0
+ ? () => _bulkDelete(context, ref, uiState, uiStateNotifier)
+ : null,
+ ),
+ ],
+ );
+ }
+
+ AppBar _buildNormalAppBar(
+ BuildContext context,
+ WidgetRef ref,
+ ValueNotifier>?>
+ treeController,
+ ObjectRef> expandedGuids,
+ ValueNotifier hideEmptyRoots,
+ ValueNotifier textFilterEnabled,
+ TextEditingController textFilterController,
+ BookmarkListUiStateNotifier uiStateNotifier,
+ BookmarkListUiState uiState,
+ ) {
+ return AppBar(
+ title: textFilterEnabled.value
+ ? TextField(
+ controller: textFilterController,
+ decoration: InputDecoration(
+ contentPadding: const EdgeInsets.only(top: 12),
+ border: InputBorder.none,
+ hintText: 'Filter bookmarks...',
+ floatingLabelBehavior: FloatingLabelBehavior.always,
+ suffixIcon: IconButton(
+ onPressed: () {
+ if (textFilterController.text.isNotEmpty) {
+ textFilterController.clear();
+ } else {
+ textFilterEnabled.value = false;
+ }
+ },
+ icon: const Icon(Icons.clear),
+ ),
+ ),
+ )
+ : const Text('Bookmarks'),
+ actions: [
+ if (!textFilterEnabled.value)
+ IconButton(
+ onPressed: () {
+ textFilterEnabled.value = !textFilterEnabled.value;
+ },
+ icon: const Icon(Icons.search),
+ ),
+ MenuAnchor(
+ menuChildren: [
+ SubmenuButton(
+ leadingIcon: const Icon(MdiIcons.eye),
+ menuChildren: [
+ MenuItemButton(
+ leadingIcon: const Icon(MdiIcons.expandAll),
+ child: const Text('Expand All'),
+ onPressed: () {
+ final controller = treeController.value;
+ if (controller != null) {
+ controller.expandAllChildren(
+ controller.tree,
+ recursive: true,
+ );
+ }
+ },
+ ),
+ if (entryGuid == BookmarkRoot.root.id)
+ MenuItemButton(
+ leadingIcon: Icon(
+ hideEmptyRoots.value
+ ? MdiIcons.folderOff
+ : MdiIcons.folder,
+ ),
+ child: Text(
+ hideEmptyRoots.value
+ ? 'Show Empty Folders'
+ : 'Hide Empty Folders',
+ ),
+ onPressed: () {
+ hideEmptyRoots.value = !hideEmptyRoots.value;
+ },
+ ),
+ MenuItemButton(
+ leadingIcon: Icon(
+ uiState.foldersOnly
+ ? MdiIcons.bookmarkMultiple
+ : MdiIcons.folderOutline,
+ ),
+ child: Text(
+ uiState.foldersOnly ? 'Show Bookmarks' : 'Folders Only',
+ ),
+ onPressed: () {
+ _snapshotExpansion(treeController.value, expandedGuids);
+ uiStateNotifier.toggleFoldersOnly();
+ },
+ ),
+ ],
+ child: const Text('Visibility'),
+ ),
+ SubmenuButton(
+ leadingIcon: const Icon(MdiIcons.sort),
+ menuChildren: [
+ for (final sortType in BookmarkSortType.values)
+ MenuItemButton(
+ leadingIcon: sortType == uiState.sortType
+ ? const Icon(Icons.check)
+ : const SizedBox(width: 24),
+ child: Text(sortType.label),
+ onPressed: () {
+ _snapshotExpansion(treeController.value, expandedGuids);
+ uiStateNotifier.setSortType(sortType);
+ },
+ ),
+ ],
+ child: const Text('Sort'),
+ ),
+ SubmenuButton(
+ leadingIcon: const Icon(MdiIcons.import),
+ menuChildren: [
+ MenuItemButton(
+ leadingIcon: const Icon(MdiIcons.codeJson),
+ child: const Text('JSON'),
+ onPressed: () => _handleImport(context, ref, 'json'),
+ ),
+ MenuItemButton(
+ leadingIcon: const Icon(MdiIcons.xml),
+ child: const Text('HTML'),
+ onPressed: () => _handleImport(context, ref, 'html'),
+ ),
+ ],
+ child: const Text('Import'),
+ ),
+ SubmenuButton(
+ leadingIcon: const Icon(MdiIcons.export),
+ menuChildren: [
+ MenuItemButton(
+ leadingIcon: const Icon(MdiIcons.codeJson),
+ child: const Text('JSON'),
+ onPressed: () => _handleExport(context, ref, 'json'),
+ ),
+ MenuItemButton(
+ leadingIcon: const Icon(MdiIcons.xml),
+ child: const Text('HTML'),
+ onPressed: () => _handleExport(context, ref, 'html'),
+ ),
+ ],
+ child: const Text('Export'),
+ ),
+ ],
+ builder: (context, controller, child) => IconButton(
+ onPressed: () {
+ if (controller.isOpen) {
+ controller.close();
+ } else {
+ controller.open();
+ }
+ },
+ icon: const Icon(MdiIcons.dotsVertical),
+ ),
+ ),
+ ],
+ );
+ }
+
+ // -- Entry Tile --
+
+ Widget _buildEntryTile(
+ BuildContext context,
+ WidgetRef ref,
+ BookmarkEntry bookmark,
+ BookmarkListUiState uiState,
+ BookmarkListUiStateNotifier uiStateNotifier,
+ bool isSelected,
+ BookmarkItem? rootItem,
+ ) {
+ if (uiState.selectionMode) {
+ return ListTile(
+ key: ValueKey(bookmark.guid),
+ contentPadding: EdgeInsets.zero,
+ leading: UrlIcon([bookmark.url], iconSize: 34.0),
+ trailing: Checkbox(
+ value: isSelected,
+ onChanged: (_) => uiStateNotifier.toggleSelection(bookmark.guid),
+ ),
+ title: Text(
+ bookmark.title,
+ maxLines: 3,
+ overflow: TextOverflow.ellipsis,
+ ),
+ subtitle: UriBreadcrumb(uri: bookmark.url),
+ onTap: () => uiStateNotifier.toggleSelection(bookmark.guid),
+ );
+ }
+
+ return ListTile(
+ key: ValueKey(bookmark.guid),
+ contentPadding: EdgeInsets.zero,
+ leading: UrlIcon([bookmark.url], iconSize: 34.0),
+ trailing: _buildEntryMenu(context, ref, bookmark, rootItem),
+ title: Text(bookmark.title, maxLines: 3, overflow: TextOverflow.ellipsis),
+ subtitle: UriBreadcrumb(uri: bookmark.url),
+ onTap: () async {
+ final result = await OpenSharedContentRoute(
+ sharedUrl: bookmark.url.toString(),
+ ).push(context);
+
+ if (result == true) {
+ if (context.mounted) {
+ const BrowserRoute().go(context);
+ }
+ }
+ },
+ onLongPress: () {
+ uiStateNotifier.enterSelectionMode(initialGuid: bookmark.guid);
+ },
+ );
+ }
+
+ Widget _buildEntryMenu(
+ BuildContext context,
+ WidgetRef ref,
+ BookmarkEntry bookmark,
+ BookmarkItem? rootItem,
+ ) {
+ return HookBuilder(
+ builder: (context) {
+ final controller = useMenuController();
+
+ return MenuAnchor(
+ controller: controller,
+ builder: (context, controller, child) => InkWell(
+ onTap: () {
+ if (controller.isOpen) {
+ controller.close();
+ } else {
+ controller.open();
+ }
+ },
+ child: const Padding(
+ padding: EdgeInsets.symmetric(horizontal: 8.0, vertical: 15.0),
+ child: Icon(MdiIcons.dotsVertical),
+ ),
+ ),
+ menuChildren: [
+ MenuItemButton(
+ leadingIcon: const Icon(MdiIcons.openInNew),
+ child: const Text('Open'),
+ onPressed: () async {
+ final result = await OpenSharedContentRoute(
+ sharedUrl: bookmark.url.toString(),
+ ).push(context);
+ if (result == true && context.mounted) {
+ const BrowserRoute().go(context);
+ }
+ },
+ ),
+ MenuItemButton(
+ leadingIcon: const Icon(MdiIcons.tabPlus),
+ child: const Text('Open in New Tab'),
+ onPressed: () async {
+ await _openInNewTab(
+ context,
+ ref,
+ bookmark.url,
+ selectTab: true,
+ );
+ },
+ ),
+ MenuItemButton(
+ leadingIcon: const Icon(MdiIcons.tab),
+ child: const Text('Open in Background'),
+ onPressed: () async {
+ await _openInNewTab(
+ context,
+ ref,
+ bookmark.url,
+ selectTab: false,
+ );
+ },
+ ),
+ MenuItemButton(
+ leadingIcon: const Icon(Icons.share),
+ child: const Text('Share'),
+ onPressed: () async {
+ await SharePlus.instance.share(
+ ShareParams(text: bookmark.url.toString()),
+ );
+ },
+ ),
+ MenuItemButton(
+ leadingIcon: const Icon(MdiIcons.folderMove),
+ child: const Text('Move'),
+ onPressed: () async {
+ final targetGuid = await showSelectBookmarkFolderDialog(
+ context,
+ initialFolderGuid: bookmark.parentGuid,
+ );
+ if (targetGuid != null) {
+ await ref
+ .read(bookmarksRepositoryProvider.notifier)
+ .editBookmark(
+ guid: bookmark.guid,
+ parentGuid: targetGuid,
+ );
+ }
+ },
+ ),
+ MenuItemButton(
+ leadingIcon: const Icon(Icons.edit),
+ child: const Text('Edit'),
+ onPressed: () async {
+ await BookmarkEntryEditRoute(
+ bookmarkEntry: jsonEncode(bookmark.toJson()),
+ ).push(context);
+ },
+ ),
+ MenuItemButton(
+ leadingIcon: const Icon(MdiIcons.bookmarkRemove),
+ child: const Text('Delete'),
+ onPressed: () async {
+ final result = await showDeleteBookmarkDialog(context);
+ if (result == true) {
+ await ref
+ .read(bookmarksRepositoryProvider.notifier)
+ .delete(bookmark.guid);
+ }
+ },
+ ),
+ ],
+ );
+ },
+ );
+ }
+
+ // -- Folder Tile --
+
+ Widget _buildFolderTile(
+ BuildContext context,
+ WidgetRef ref,
+ BookmarkFolder folder, {
+ required bool isLeaf,
+ required bool isExpanded,
+ required BookmarkListUiState uiState,
+ required BookmarkListUiStateNotifier uiStateNotifier,
+ required bool isSelected,
+ required BookmarkItem? rootItem,
+ }) {
+ final isRoot = bookmarkRootIds.contains(folder.guid);
+
+ if (uiState.selectionMode) {
+ return Padding(
+ key: ValueKey(folder.guid),
+ padding: isLeaf
+ ? const EdgeInsets.only(right: 4.0)
+ : const EdgeInsets.only(right: 42.0),
+ child: ListTile(
+ contentPadding: EdgeInsets.zero,
+ leading: isExpanded
+ ? const Icon(MdiIcons.folderOpen)
+ : const Icon(MdiIcons.folder),
+ title: Text(folder.title),
+ trailing: isRoot
+ ? null
+ : Checkbox(
+ value: isSelected,
+ onChanged: (_) =>
+ uiStateNotifier.toggleSelection(folder.guid),
+ ),
+ onTap: isRoot
+ ? null
+ : () => uiStateNotifier.toggleSelection(folder.guid),
+ ),
+ );
+ }
+
+ return Padding(
+ key: ValueKey(folder.guid),
+ padding: isLeaf
+ ? const EdgeInsets.only(right: 4.0)
+ : const EdgeInsets.only(right: 42.0),
+ child: HookBuilder(
+ builder: (context) {
+ final controller = useMenuController();
+
+ return ListTile(
+ contentPadding: EdgeInsets.zero,
+ leading: isExpanded
+ ? const Icon(MdiIcons.folderOpen)
+ : const Icon(MdiIcons.folder),
+ title: Text(folder.title),
+ trailing: MenuAnchor(
+ controller: controller,
+ builder: (context, controller, child) => InkWell(
+ onTap: () {
+ if (controller.isOpen) {
+ controller.close();
+ } else {
+ controller.open();
+ }
+ },
+ child: const Padding(
+ padding: EdgeInsets.symmetric(
+ horizontal: 8.0,
+ vertical: 15.0,
+ ),
+ child: Icon(MdiIcons.dotsVertical),
+ ),
+ ),
+ menuChildren: [
+ if (!isRoot) ...[
+ MenuItemButton(
+ leadingIcon: const Icon(MdiIcons.folderMove),
+ child: const Text('Move'),
+ onPressed: () async {
+ final repo = ref.read(
+ bookmarksRepositoryProvider.notifier,
+ );
+ final descendantGuids = await repo
+ .getDescendantFolderGuids(folder.guid);
+ final excludeGuids = {folder.guid, ...descendantGuids};
+ if (!context.mounted) return;
+ final targetGuid = await showSelectBookmarkFolderDialog(
+ context,
+ excludeFolderGuids: excludeGuids,
+ initialFolderGuid: folder.parentGuid,
+ );
+ if (targetGuid != null) {
+ await repo.editFolder(
+ guid: folder.guid,
+ parentGuid: targetGuid,
+ );
+ }
+ },
+ ),
+ if (canFlattenFolder(folder))
+ MenuItemButton(
+ leadingIcon: const Icon(MdiIcons.folderRemove),
+ child: const Text('Flatten'),
+ onPressed: () async {
+ await ref
+ .read(bookmarksRepositoryProvider.notifier)
+ .flattenFolder(folder: folder);
+ },
+ ),
+ MenuItemButton(
+ leadingIcon: const Icon(MdiIcons.folderEdit),
+ child: const Text('Edit'),
+ onPressed: () async {
+ await BookmarkFolderEditRoute(
+ folder: jsonEncode(folder.toJson()),
+ ).push(context);
+ },
+ ),
+ MenuItemButton(
+ leadingIcon: const Icon(Icons.delete),
+ child: const Text('Delete'),
+ onPressed: () async {
+ final result = await showDeleteFolderDialog(context);
+ if (result == true) {
+ await ref
+ .read(bookmarksRepositoryProvider.notifier)
+ .delete(folder.guid);
+ }
+ },
+ ),
+ ],
+ MenuItemButton(
+ leadingIcon: const Icon(MdiIcons.folderPlus),
+ child: const Text('Add Subfolder'),
+ onPressed: () async {
+ await BookmarkFolderAddRoute(
+ parentGuid: folder.guid,
+ ).push(context);
+ },
+ ),
+ MenuItemButton(
+ leadingIcon: const Icon(MdiIcons.bookmarkPlus),
+ child: const Text('Add Bookmark'),
+ onPressed: () async {
+ await BookmarkEntryAddRoute(
+ bookmarkInfo: jsonEncode(
+ BookmarkInfo(parentGuid: folder.guid).encode(),
+ ),
+ ).push(context);
+ },
+ ),
+ ],
+ ),
+ onTap: () async {
+ if (folder.guid != entryGuid) {
+ await BookmarkListRoute(entryGuid: folder.guid).push(context);
+ }
+ },
+ onLongPress: isRoot
+ ? null
+ : () {
+ ref
+ .read(bookmarkListUiStateProvider.notifier)
+ .enterSelectionMode(initialGuid: folder.guid);
+ },
+ );
+ },
+ ),
+ );
+ }
+
+ // -- Bulk Actions --
+
+ Future _bulkOpenInBackground(
+ BuildContext context,
+ WidgetRef ref,
+ BookmarkListUiState uiState,
+ ) async {
+ final bookmarkData = ref.read(
+ seamlessBookmarksProvider(entryGuid, hideEmptyRoots: true),
+ );
+ final root = bookmarkData.value;
+ if (root == null) return;
+
+ final items = resolveSelectedItems(root, uiState.selectedGuids);
+ final entries = items.whereType().toList();
+
+ if (entries.isEmpty) {
+ if (context.mounted) {
+ showInfoMessage(context, 'No bookmark entries selected');
+ }
+ return;
+ }
+
+ final currentTab = ref.read(selectedTabStateProvider);
+ final tabMode =
+ currentTab?.tabMode ??
+ TabMode.fromTabType(
+ ref
+ .read(generalSettingsWithDefaultsProvider)
+ .effectiveDefaultCreateTabType,
+ );
+
+ for (final entry in entries) {
+ await ref
+ .read(tabRepositoryProvider.notifier)
+ .addTab(url: entry.url, selectTab: false, tabMode: tabMode);
+ }
+
+ ref.read(bookmarkListUiStateProvider.notifier).exitSelectionMode();
+
+ if (context.mounted) {
+ showInfoMessage(context, 'Opened ${entries.length} tabs in background');
+ }
+ }
+
+ Future _bulkMove(
+ BuildContext context,
+ WidgetRef ref,
+ BookmarkListUiState uiState,
+ ) async {
+ final bookmarkData = ref.read(
+ seamlessBookmarksProvider(entryGuid, hideEmptyRoots: true),
+ );
+ final root = bookmarkData.value;
+ if (root == null) return;
+
+ final items = resolveSelectedItems(root, uiState.selectedGuids);
+
+ // Build exclusion set from selected folders and their full descendant
+ // trees fetched from storage, so hidden folders (e.g. filtered by search)
+ // are still properly excluded as move targets.
+ final repo = ref.read(bookmarksRepositoryProvider.notifier);
+ final excludeGuids = {};
+ for (final item in items) {
+ if (item is BookmarkFolder) {
+ excludeGuids.add(item.guid);
+ excludeGuids.addAll(await repo.getDescendantFolderGuids(item.guid));
+ }
+ }
+
+ if (!context.mounted) return;
+
+ final targetGuid = await showSelectBookmarkFolderDialog(
+ context,
+ excludeFolderGuids: excludeGuids,
+ );
+
+ if (targetGuid == null) return;
+
+ // Normalize selection to avoid double-moves
+ final normalizedGuids = normalizeSelection(root, uiState.selectedGuids);
+ final normalizedItems = resolveSelectedItems(root, normalizedGuids);
+
+ await ref
+ .read(bookmarksRepositoryProvider.notifier)
+ .moveMany(items: normalizedItems, targetParentGuid: targetGuid);
+
+ ref.read(bookmarkListUiStateProvider.notifier).exitSelectionMode();
+
+ if (context.mounted) {
+ showInfoMessage(context, 'Moved ${normalizedItems.length} items');
+ }
+ }
+
+ Future _bulkDelete(
+ BuildContext context,
+ WidgetRef ref,
+ BookmarkListUiState uiState,
+ BookmarkListUiStateNotifier uiStateNotifier,
+ ) async {
+ final bookmarkData = ref.read(
+ seamlessBookmarksProvider(entryGuid, hideEmptyRoots: true),
+ );
+ final root = bookmarkData.value;
+ if (root == null) return;
+
+ final items = resolveSelectedItems(root, uiState.selectedGuids);
+ final hasFolders = items.any((item) => item is BookmarkFolder);
+
+ if (!context.mounted) return;
+ final result = await (hasFolders
+ ? showDeleteFolderDialog(context)
+ : showDeleteBookmarkDialog(context));
+ if (result != true) return;
+
+ // Normalize to avoid deleting children whose parent folder is also being deleted
+ final normalizedGuids = normalizeSelection(root, uiState.selectedGuids);
+
+ await ref
+ .read(bookmarksRepositoryProvider.notifier)
+ .deleteMany(normalizedGuids);
+
+ uiStateNotifier.exitSelectionMode();
+
+ if (context.mounted) {
+ showInfoMessage(context, 'Deleted ${normalizedGuids.length} items');
+ }
+ }
+
+ // -- Tree Expansion State Helpers --
+
+ /// Collects the GUIDs of all currently expanded nodes from the tree.
+ void _snapshotExpansion(
+ TreeViewController>? controller,
+ ObjectRef> expandedGuids,
+ ) {
+ if (controller == null) return;
+ final guids = {};
+ _collectExpandedGuids(controller.tree, guids);
+ expandedGuids.value = guids;
+ }
+
+ void _collectExpandedGuids(TreeNode node, Set guids) {
+ if (node.isExpanded && node.key != INode.ROOT_KEY) {
+ guids.add(node.key);
+ }
+ for (final child in node.childrenAsList) {
+ _collectExpandedGuids(child as TreeNode, guids);
+ }
+ }
+
+ /// Restores expansion state by expanding nodes whose GUIDs are in the set.
+ void _restoreExpansion(
+ TreeViewController> controller,
+ TreeNode root,
+ Set guids,
+ ) {
+ // Always expand root
+ controller.expandNode(root);
+ _expandMatchingNodes(controller, root, guids);
+ }
+
+ void _expandMatchingNodes(
+ TreeViewController> controller,
+ TreeNode node,
+ Set guids,
+ ) {
+ for (final child in node.childrenAsList) {
+ final typedChild = child as TreeNode;
+ if (guids.contains(typedChild.key)) {
+ controller.expandNode(typedChild);
+ }
+ _expandMatchingNodes(controller, typedChild, guids);
+ }
+ }
+
+ // -- Tab Opening Helper --
+
+ Future _openInNewTab(
+ BuildContext context,
+ WidgetRef ref,
+ Uri url, {
+ required bool selectTab,
+ }) async {
+ final currentTab = ref.read(selectedTabStateProvider);
+ final tabMode =
+ currentTab?.tabMode ??
+ TabMode.fromTabType(
+ ref
+ .read(generalSettingsWithDefaultsProvider)
+ .effectiveDefaultCreateTabType,
+ );
+
+ final tabId = await ref
+ .read(tabRepositoryProvider.notifier)
+ .addTab(
+ url: url,
+ parentId: currentTab?.id,
+ selectTab: selectTab,
+ tabMode: tabMode,
+ );
+
+ if (selectTab) {
+ if (context.mounted) {
+ const BrowserRoute().go(context);
+ }
+ } else {
+ if (context.mounted) {
+ final repo = ref.read(tabRepositoryProvider.notifier);
+ showTabSwitchMessage(
+ context,
+ onSwitch: () async {
+ await repo.selectTab(tabId);
+ },
+ );
+ }
+ }
+ }
+
+ // -- Import/Export (unchanged) --
+
Future _handleImport(
BuildContext context,
WidgetRef ref,
diff --git a/app/lib/features/geckoview/features/bookmarks/presentation/widgets/folder_tree_picker.dart b/app/lib/features/geckoview/features/bookmarks/presentation/widgets/folder_tree_picker.dart
index 5b17b9e0..8a1d6617 100644
--- a/app/lib/features/geckoview/features/bookmarks/presentation/widgets/folder_tree_picker.dart
+++ b/app/lib/features/geckoview/features/bookmarks/presentation/widgets/folder_tree_picker.dart
@@ -31,22 +31,22 @@ import 'package:weblibre/presentation/widgets/failure_widget.dart';
/// A widget that displays a tree view of bookmark folders and allows the user
/// to select a parent folder.
///
-/// When editing a folder, pass [excludeFolderGuid] to prevent selecting the
-/// folder itself or its descendants as the parent (which would create a circular reference).
+/// When editing a folder, pass [excludeFolderGuids] to prevent selecting the
+/// folders or their descendants as the parent (which would create a circular reference).
class FolderTreePicker extends HookConsumerWidget {
/// The currently selected folder GUID
final ValueNotifier selectedFolderGuid;
- /// Optional folder GUID to exclude from the tree (along with its descendants).
- /// Used when editing a folder to prevent circular parent relationships.
- final String? excludeFolderGuid;
+ /// Optional folder GUIDs to exclude from the tree (along with their descendants).
+ /// Used when editing/moving folders to prevent circular parent relationships.
+ final Set excludeFolderGuids;
final String entryGuid;
const FolderTreePicker({
required this.selectedFolderGuid,
required this.entryGuid,
- this.excludeFolderGuid,
+ this.excludeFolderGuids = const {},
super.key,
});
@@ -72,9 +72,9 @@ class FolderTreePicker extends HookConsumerWidget {
if (item.children != null) {
for (final child in item.children!) {
- // Skip the excluded folder and its descendants
+ // Skip excluded folders and their descendants
if (child is BookmarkFolder &&
- child.guid != excludeFolderGuid) {
+ !excludeFolderGuids.contains(child.guid)) {
addChildren(node, child);
}
}