add open in new tab feature (#161)

This commit is contained in:
Fabian Freund
2026-02-11 11:38:58 +01:00
parent 4a22b97712
commit 750cbb1763
8 changed files with 244 additions and 41 deletions
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<ContainerData?>(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()),
),
],
),
);
}
}
@@ -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),
@@ -92,7 +92,7 @@ final class PwaManifestStateProvider
}
}
String _$pwaManifestStateHash() => r'e5237d2aae5dce9cb805484eb2a09cc3f6aceda1';
String _$pwaManifestStateHash() => r'1d173b69e96ab71b117e82b9a4134c6732a73343';
/// Manages PWA manifest state with a long-running subscription.
@@ -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<ContainerData> createNewContainer() async {
final initialColor = await unusedRandomContainerColor();
return ContainerData(id: uuid.v7(), color: initialColor);
}
Future<bool> isSiteAssignedToContainer(Uri uri) {
return ref
.read(tabDatabaseProvider)
@@ -42,7 +42,7 @@ final class ContainerRepositoryProvider
}
String _$containerRepositoryHash() =>
r'3154277f7a6029d6f5b9fcc62ba057e117248e0c';
r'62c2b06970db4385ea0d2edc68bcf076c291a9b6';
abstract class _$ContainerRepository extends $Notifier<void> {
void build();
@@ -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<ContainerData?>(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<ContainerData?>(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;
}
},
)
@@ -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),
@@ -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),