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),