From 750cbb1763072893b5f6eee2978ef125567fe663 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Wed, 11 Feb 2026 11:38:58 +0100 Subject: [PATCH] add open in new tab feature (#161) --- .../candidates/open_in_container.dart | 201 ++++++++++++++++++ .../presentation/context_menu_dialog.dart | 3 + .../features/pwa/domain/providers.g.dart | 2 +- .../tabs/domain/repositories/container.dart | 7 + .../tabs/domain/repositories/container.g.dart | 2 +- .../screens/container_draft_suggestions.dart | 33 ++- .../presentation/screens/container_list.dart | 17 +- .../screens/container_selection.dart | 20 +- 8 files changed, 244 insertions(+), 41 deletions(-) create mode 100644 app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_in_container.dart diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_in_container.dart b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_in_container.dart new file mode 100644 index 00000000..8f584c84 --- /dev/null +++ b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_in_container.dart @@ -0,0 +1,201 @@ +/* + * 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 'dart:convert'; + +import 'package:drift/drift.dart' hide Column; +import 'package:fading_scroll/fading_scroll.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.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:skeletonizer/skeletonizer.dart'; +import 'package:uuid/enums.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/contextmenu/extensions/hit_result.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.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/presentation/widgets/container_list_tile.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/presentation/widgets/failure_widget.dart'; +import 'package:weblibre/utils/ui_helper.dart'; + +sealed class _PickerResult {} + +class _SelectedContainer extends _PickerResult { + final ContainerData container; + _SelectedContainer(this.container); +} + +class _CreateNewContainer extends _PickerResult {} + +class OpenInContainer extends HookConsumerWidget { + final HitResult hitResult; + + const OpenInContainer({super.key, required this.hitResult}); + + static bool isSupported(HitResult hitResult) { + return hitResult.isHttpLink(); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListTile( + leading: const Icon(MdiIcons.selectGroup), + title: const Text('Open in container'), + onTap: () async { + final result = await showModalBottomSheet<_PickerResult?>( + context: context, + builder: (context) => _ContainerPickerSheet(), + ); + + if (result == null || !context.mounted) return; + + final selectedContainer = switch (result) { + _SelectedContainer(:final container) => container, + _CreateNewContainer() => await () async { + final draft = await ref + .read(containerRepositoryProvider.notifier) + .createNewContainer(); + + if (!context.mounted) return null; + + return ContainerCreateRoute( + containerData: jsonEncode(draft.toJson()), + ).push(context); + }(), + }; + + if (selectedContainer == null || !context.mounted) return; + + final currentTab = ref.read(selectedTabStateProvider); + final isPrivate = + currentTab?.isPrivate ?? + ref + .read(generalSettingsWithDefaultsProvider) + .defaultCreateTabType == + TabType.private; + + final tabId = await ref + .read(tabRepositoryProvider.notifier) + .addTab( + url: hitResult.tryGetLink(), + parentId: currentTab?.id, + selectTab: false, + private: isPrivate, + container: Value(selectedContainer), + ); + + if (context.mounted) { + final repo = ref.read(tabRepositoryProvider.notifier); + + showTabSwitchMessage( + context, + onSwitch: () async { + await repo.selectTab(tabId); + }, + ); + + context.pop(); + } + }, + ); + } +} + +class _ContainerPickerSheet extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final containersAsync = ref.watch(watchContainersWithCountProvider); + + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Select Container', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + Flexible( + child: Skeletonizer( + enabled: containersAsync.isLoading, + child: containersAsync.when( + skipLoadingOnReload: true, + data: (containers) => FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return ListView.builder( + controller: controller, + shrinkWrap: true, + itemCount: containers.length, + itemBuilder: (context, index) { + final container = containers[index]; + return ContainerListTile( + container, + isSelected: false, + onTap: () => Navigator.pop( + context, + _SelectedContainer(container), + ), + ); + }, + ); + }, + ), + error: (error, stackTrace) => Center( + child: FailureWidget( + title: 'Failed to load containers', + exception: error, + onRetry: () => + ref.invalidate(watchContainersWithCountProvider), + ), + ), + loading: () => ListView.builder( + shrinkWrap: true, + itemCount: 3, + itemBuilder: (context, index) => ContainerListTile( + ContainerData( + id: Namespace.nil.value, + color: Colors.transparent, + ), + isSelected: false, + onTap: null, + ), + ), + ), + ), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.add), + title: const Text('New Container'), + onTap: () => Navigator.pop(context, _CreateNewContainer()), + ), + ], + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart b/app/lib/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart index b8d574d9..8feb3c98 100644 --- a/app/lib/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart +++ b/app/lib/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart @@ -30,6 +30,7 @@ import 'package:weblibre/features/geckoview/features/contextmenu/presentation/ca import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/copy_link.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/launch_external.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/open_image_new_tab.dart'; +import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/open_in_container.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/open_new_tab.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/save_file.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/save_image.dart'; @@ -57,6 +58,8 @@ class ContextMenuDialog extends HookConsumerWidget { children: [ if (OpenInNewTab.isSupported(hitResult)) OpenInNewTab(hitResult: hitResult), + if (OpenInContainer.isSupported(hitResult)) + OpenInContainer(hitResult: hitResult), if (CopyLink.isSupported(hitResult)) CopyLink(hitResult: hitResult), if (SaveFile.isSupported(hitResult)) SaveFile(hitResult: hitResult), if (ShareLink.isSupported(hitResult)) ShareLink(hitResult: hitResult), diff --git a/app/lib/features/geckoview/features/pwa/domain/providers.g.dart b/app/lib/features/geckoview/features/pwa/domain/providers.g.dart index 7daf9555..975d8922 100644 --- a/app/lib/features/geckoview/features/pwa/domain/providers.g.dart +++ b/app/lib/features/geckoview/features/pwa/domain/providers.g.dart @@ -92,7 +92,7 @@ final class PwaManifestStateProvider } } -String _$pwaManifestStateHash() => r'e5237d2aae5dce9cb805484eb2a09cc3f6aceda1'; +String _$pwaManifestStateHash() => r'1d173b69e96ab71b117e82b9a4134c6732a73343'; /// Manages PWA manifest state with a long-running subscription. diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/container.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/container.dart index 21bece87..7d8992e9 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/container.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/container.dart @@ -22,6 +22,7 @@ import 'dart:ui'; import 'package:collection/collection.dart'; import 'package:nullability/nullability.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/uuid.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; @@ -143,6 +144,12 @@ class ContainerRepository extends _$ContainerRepository { return randomColor; } + Future createNewContainer() async { + final initialColor = await unusedRandomContainerColor(); + + return ContainerData(id: uuid.v7(), color: initialColor); + } + Future isSiteAssignedToContainer(Uri uri) { return ref .read(tabDatabaseProvider) diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart index 5bc66958..355b8134 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart @@ -42,7 +42,7 @@ final class ContainerRepositoryProvider } String _$containerRepositoryHash() => - r'3154277f7a6029d6f5b9fcc62ba057e117248e0c'; + r'62c2b06970db4385ea0d2edc68bcf076c291a9b6'; abstract class _$ContainerRepository extends $Notifier { void build(); diff --git a/app/lib/features/geckoview/features/tabs/presentation/screens/container_draft_suggestions.dart b/app/lib/features/geckoview/features/tabs/presentation/screens/container_draft_suggestions.dart index 1c62f212..aa51b2d9 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/screens/container_draft_suggestions.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/screens/container_draft_suggestions.dart @@ -29,7 +29,6 @@ import 'package:nullability/nullability.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:weblibre/core/logger.dart'; import 'package:weblibre/core/routing/routes.dart'; -import 'package:weblibre/core/uuid.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart'; import 'package:weblibre/features/geckoview/features/browser/utils/grid_calculations.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; @@ -196,13 +195,11 @@ class ContainerDraftSuggestionsScreen extends HookConsumerWidget { ? FloatingActionButton( child: const Icon(MdiIcons.folderPlus), onPressed: () async { - final initialColor = await ref + final initialContainer = await ref .read(containerRepositoryProvider.notifier) - .unusedRandomContainerColor(); + .createNewContainer(); - final initialContainer = ContainerData( - id: uuid.v7(), - color: initialColor, + final namedContainer = initialContainer.copyWith( name: (ref.exists( tabsTopicProvider(EquatableValue(selectedTabs!)), @@ -218,21 +215,21 @@ class ContainerDraftSuggestionsScreen extends HookConsumerWidget { : selectedContainer.value?.topic, ); - if (context.mounted) { - final result = await ContainerCreateRoute( - containerData: jsonEncode(initialContainer.toJson()), - ).push(context); + if (!context.mounted) return; - if (result != null) { - for (final tab in selectedTabs) { - await ref - .read(tabDataRepositoryProvider.notifier) - .assignContainer(tab, result); - } + final result = await ContainerCreateRoute( + containerData: jsonEncode(namedContainer.toJson()), + ).push(context); - selectedContainerTabs.value = {}; - selectedContainer.value = null; + if (result != null) { + for (final tab in selectedTabs) { + await ref + .read(tabDataRepositoryProvider.notifier) + .assignContainer(tab, result); } + + selectedContainerTabs.value = {}; + selectedContainer.value = null; } }, ) diff --git a/app/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart b/app/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart index b8f29075..0f156f9e 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart @@ -25,7 +25,6 @@ import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:weblibre/core/routing/routes.dart'; -import 'package:weblibre/core/uuid.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; @@ -156,17 +155,15 @@ class ContainerListScreen extends HookConsumerWidget { ), floatingActionButton: FloatingActionButton.extended( onPressed: () async { - final initialColor = await ref + final newContainer = await ref .read(containerRepositoryProvider.notifier) - .unusedRandomContainerColor(); + .createNewContainer(); - if (context.mounted) { - await ContainerCreateRoute( - containerData: jsonEncode( - ContainerData(id: uuid.v7(), color: initialColor).toJson(), - ), - ).push(context); - } + if (!context.mounted) return; + + await ContainerCreateRoute( + containerData: jsonEncode(newContainer.toJson()), + ).push(context); }, label: const Text('Container'), icon: const Icon(Icons.add), diff --git a/app/lib/features/geckoview/features/tabs/presentation/screens/container_selection.dart b/app/lib/features/geckoview/features/tabs/presentation/screens/container_selection.dart index 001b25da..932df227 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/screens/container_selection.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/screens/container_selection.dart @@ -24,8 +24,8 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; +import 'package:uuid/enums.dart'; import 'package:weblibre/core/routing/routes.dart'; -import 'package:weblibre/core/uuid.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; @@ -73,7 +73,7 @@ class ContainerSelectionScreen extends HookConsumerWidget { loading: () => ListView.builder( itemCount: 3, itemBuilder: (context, index) => ContainerListTile( - ContainerData(id: 'null', color: Colors.transparent), + ContainerData(id: Namespace.nil.value, color: Colors.transparent), isSelected: false, onTap: null, ), @@ -82,17 +82,15 @@ class ContainerSelectionScreen extends HookConsumerWidget { ), floatingActionButton: FloatingActionButton.extended( onPressed: () async { - final initialColor = await ref + final newContainer = await ref .read(containerRepositoryProvider.notifier) - .unusedRandomContainerColor(); + .createNewContainer(); - if (context.mounted) { - await ContainerCreateRoute( - containerData: jsonEncode( - ContainerData(id: uuid.v7(), color: initialColor).toJson(), - ), - ).push(context); - } + if (!context.mounted) return; + + await ContainerCreateRoute( + containerData: jsonEncode(newContainer.toJson()), + ).push(context); }, label: const Text('Container'), icon: const Icon(Icons.add),