diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart
index bf73ea1f..4ec3606f 100644
--- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart
+++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart
@@ -63,6 +63,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/entities/contai
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
+import 'package:weblibre/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart';
import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart';
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
@@ -383,6 +384,10 @@ class _PageActionsCard extends HookConsumerWidget {
),
_buildDivider(),
+ // Pin/Unpin Top Site
+ _PinTopSiteTile(selectedTabId: selectedTabId),
+ _buildDivider(),
+
// Find in page
ListTile(
leading: const Icon(Icons.search),
@@ -575,6 +580,62 @@ class _AddToHomeScreenTile extends ConsumerWidget {
}
}
+class _PinTopSiteTile extends HookConsumerWidget {
+ final String selectedTabId;
+
+ const _PinTopSiteTile({required this.selectedTabId});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final tabState = ref.watch(tabStateProvider(selectedTabId));
+ final url = tabState?.url;
+
+ final isPinned = useCachedFuture(
+ () => url != null
+ ? ref
+ .read(topSiteRepositoryProvider.notifier)
+ .isPersistedTopSiteUrl(url)
+ : Future.value(false),
+ [url],
+ );
+
+ final pinned = isPinned.data ?? false;
+
+ return ListTile(
+ leading: Icon(pinned ? MdiIcons.pinOff : MdiIcons.pin),
+ title: Text(pinned ? 'Unpin from Top Sites' : 'Pin to Top Sites'),
+ onTap: () async {
+ if (tabState == null || url == null) return;
+ Navigator.pop(context);
+ try {
+ if (pinned) {
+ await ref
+ .read(topSiteRepositoryProvider.notifier)
+ .unpinSiteByUrl(url);
+ if (context.mounted) {
+ ui_helper.showInfoMessage(context, 'Unpinned from Top Sites');
+ }
+ } else {
+ await ref
+ .read(topSiteRepositoryProvider.notifier)
+ .addPinnedSite(
+ title: tabState.titleOrAuthority,
+ url: url,
+ );
+ if (context.mounted) {
+ ui_helper.showInfoMessage(context, 'Pinned to Top Sites');
+ }
+ }
+ } catch (e) {
+ if (context.mounted) {
+ ui_helper.showErrorMessage(context, 'Failed to update Top Sites');
+ }
+ }
+ },
+ );
+ }
+}
+
class _TranslatePageTile extends ConsumerWidget {
final String selectedTabId;
diff --git a/app/lib/features/geckoview/features/search/domain/providers/search_modules_view.dart b/app/lib/features/geckoview/features/search/domain/providers/search_modules_view.dart
index 26642506..3b8d7f93 100644
--- a/app/lib/features/geckoview/features/search/domain/providers/search_modules_view.dart
+++ b/app/lib/features/geckoview/features/search/domain/providers/search_modules_view.dart
@@ -27,6 +27,7 @@ enum SearchModuleType {
bookmarks,
history,
historyHighlights,
+ topSites,
recentHistory,
recentArticles,
recentTabs,
diff --git a/app/lib/features/geckoview/features/search/presentation/dialogs/edit_top_site_dialog.dart b/app/lib/features/geckoview/features/search/presentation/dialogs/edit_top_site_dialog.dart
new file mode 100644
index 00000000..385cd9b4
--- /dev/null
+++ b/app/lib/features/geckoview/features/search/presentation/dialogs/edit_top_site_dialog.dart
@@ -0,0 +1,127 @@
+/*
+ * 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';
+
+Future<({String title, Uri url})?> showEditTopSiteDialog(
+ BuildContext context, {
+ required String initialTitle,
+ required Uri initialUrl,
+}) {
+ return showDialog<({String title, Uri url})>(
+ context: context,
+ builder: (context) => _EditTopSiteDialog(
+ initialTitle: initialTitle,
+ initialUrl: initialUrl,
+ ),
+ );
+}
+
+class _EditTopSiteDialog extends StatefulWidget {
+ final String initialTitle;
+ final Uri initialUrl;
+
+ const _EditTopSiteDialog({
+ required this.initialTitle,
+ required this.initialUrl,
+ });
+
+ @override
+ State<_EditTopSiteDialog> createState() => _EditTopSiteDialogState();
+}
+
+class _EditTopSiteDialogState extends State<_EditTopSiteDialog> {
+ late final TextEditingController _titleController;
+ late final TextEditingController _urlController;
+ final _formKey = GlobalKey();
+
+ @override
+ void initState() {
+ super.initState();
+ _titleController = TextEditingController(text: widget.initialTitle);
+ _urlController = TextEditingController(text: widget.initialUrl.toString());
+ }
+
+ @override
+ void dispose() {
+ _titleController.dispose();
+ _urlController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return AlertDialog(
+ title: const Text('Edit Top Site'),
+ content: Form(
+ key: _formKey,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ TextFormField(
+ controller: _titleController,
+ decoration: const InputDecoration(labelText: 'Title'),
+ autofocus: true,
+ validator: (value) {
+ if (value == null || value.trim().isEmpty) {
+ return 'Title cannot be empty';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 12),
+ TextFormField(
+ controller: _urlController,
+ decoration: const InputDecoration(labelText: 'URL'),
+ keyboardType: TextInputType.url,
+ validator: (value) {
+ if (value == null || value.trim().isEmpty) {
+ return 'URL cannot be empty';
+ }
+ final uri = Uri.tryParse(value.trim());
+ if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
+ return 'Enter a valid URL';
+ }
+ return null;
+ },
+ ),
+ ],
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: () {
+ if (_formKey.currentState?.validate() == true) {
+ final url = Uri.parse(_urlController.text.trim());
+ Navigator.pop(
+ context,
+ (title: _titleController.text.trim(), url: url),
+ );
+ }
+ },
+ child: const Text('Save'),
+ ),
+ ],
+ );
+ }
+}
diff --git a/app/lib/features/geckoview/features/search/presentation/screens/search.dart b/app/lib/features/geckoview/features/search/presentation/screens/search.dart
index 247c0d84..29e8770f 100644
--- a/app/lib/features/geckoview/features/search/presentation/screens/search.dart
+++ b/app/lib/features/geckoview/features/search/presentation/screens/search.dart
@@ -36,10 +36,10 @@ import 'package:weblibre/features/geckoview/features/browser/domain/providers.da
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/animated_tab_type_switcher.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/clipboard_fill.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/containers_section.dart';
-import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/history_highlights_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/recent_feed_articles_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/recent_history_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/recent_tabs_section.dart';
+import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/top_sites_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_field.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/bookmark_search.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart';
@@ -503,7 +503,7 @@ class SearchScreen extends HookConsumerWidget {
child: ClipboardFillLink(controller: searchTextController),
),
if (showNoInputSections) ...[
- HistoryHighlightsSection(
+ TopSitesSection(
onUriSelected: (uri) async {
if (isEditMode) {
await ref
@@ -539,7 +539,9 @@ class SearchScreen extends HookConsumerWidget {
),
RecentFeedArticlesSection(
onArticleSelected: (article) {
- FeedArticleRoute(articleId: article.id).go(context);
+ unawaited(
+ FeedArticleRoute(articleId: article.id).push(context),
+ );
},
),
RecentTabsSection(
diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/empty_state/top_sites_section.dart b/app/lib/features/geckoview/features/search/presentation/widgets/empty_state/top_sites_section.dart
new file mode 100644
index 00000000..67902682
--- /dev/null
+++ b/app/lib/features/geckoview/features/search/presentation/widgets/empty_state/top_sites_section.dart
@@ -0,0 +1,582 @@
+/*
+ * 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_reorderable_grid_view/widgets/custom_draggable.dart';
+import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
+import 'package:weblibre/features/geckoview/features/search/presentation/dialogs/edit_top_site_dialog.dart';
+import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart';
+import 'package:weblibre/features/geckoview/features/top_sites/domain/entities/top_site_item.dart';
+import 'package:weblibre/features/geckoview/features/top_sites/domain/entities/top_site_source.dart';
+import 'package:weblibre/features/geckoview/features/top_sites/domain/providers.dart';
+import 'package:weblibre/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart';
+import 'package:weblibre/presentation/widgets/url_icon.dart';
+import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
+
+const _gridMainAxisSpacing = 8.0;
+const _gridCrossAxisSpacing = 8.0;
+const _topSitesPreviewLimit = 8;
+const _topSitesMaxLimit = 25;
+
+class _TopSitesGridLayout {
+ final int crossAxisCount;
+ final double childAspectRatio;
+
+ const _TopSitesGridLayout({
+ required this.crossAxisCount,
+ required this.childAspectRatio,
+ });
+}
+
+_TopSitesGridLayout _resolveGridLayout(double width) {
+ const minTileWidth = 74.0;
+ const effectiveTileWidth = minTileWidth + _gridCrossAxisSpacing;
+ final rawCount = (width / effectiveTileWidth).floor();
+ final crossAxisCount = rawCount.clamp(4, 7);
+
+ // Keep cells compact on phones and allow slightly wider tiles on large screens.
+ final childAspectRatio = switch (crossAxisCount) {
+ 4 => 0.92,
+ 5 => 0.96,
+ _ => 1.0,
+ };
+
+ return _TopSitesGridLayout(
+ crossAxisCount: crossAxisCount,
+ childAspectRatio: childAspectRatio,
+ );
+}
+
+class TopSitesSection extends HookConsumerWidget {
+ final void Function(Uri uri) onUriSelected;
+
+ const TopSitesSection({super.key, required this.onUriSelected});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final topSitesAsync = ref.watch(
+ topSiteListProvider(limit: _topSitesMaxLimit),
+ );
+ final topSites = topSitesAsync.value ?? [];
+
+ if (topSitesAsync.hasValue && topSites.isEmpty) {
+ return const SliverToBoxAdapter(child: SizedBox.shrink());
+ }
+
+ final reorderMode = useState(false);
+ final reorderBusy = useState(false);
+
+ final persistedItems = topSites.where((s) => s.isPersisted).toList();
+ final historyItems = topSites.where((s) => !s.isPersisted).toList();
+
+ return SearchModuleSection(
+ title: 'Top Sites',
+ moduleType: SearchModuleType.topSites,
+ totalCount: topSites.length,
+ previewLimit: _topSitesPreviewLimit,
+ headerTrailing: persistedItems.length >= 2
+ ? IconButton.filledTonal(
+ icon: const Icon(Icons.swap_vert),
+ visualDensity: VisualDensity.compact,
+ isSelected: reorderMode.value,
+ iconSize: 18,
+ padding: EdgeInsets.zero,
+ tooltip: reorderMode.value
+ ? 'Disable reordering mode'
+ : 'Enable reordering mode',
+ onPressed: () {
+ final wasEnabled = reorderMode.value;
+ reorderMode.value = !wasEnabled;
+ if (!wasEnabled && context.mounted) {
+ ui_helper.showInfoMessage(
+ context,
+ 'Drag and drop top sites to reorder',
+ );
+ }
+ },
+ )
+ : null,
+ contentSliverBuilder:
+ ({required bool isCollapsed, required int visibleCount}) => [
+ if (!isCollapsed)
+ if (reorderMode.value)
+ _ReorderableTopSitesGrid(
+ persistedItems: persistedItems,
+ historyItems: historyItems,
+ reorderBusy: reorderBusy,
+ onUriSelected: onUriSelected,
+ )
+ else
+ _TopSitesGrid(
+ items: topSites,
+ visibleCount: visibleCount,
+ onUriSelected: onUriSelected,
+ ),
+ ],
+ );
+ }
+}
+
+class _TopSitesGrid extends ConsumerWidget {
+ final List items;
+ final int visibleCount;
+ final void Function(Uri uri) onUriSelected;
+
+ const _TopSitesGrid({
+ required this.items,
+ required this.visibleCount,
+ required this.onUriSelected,
+ });
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final displayItems = items.take(visibleCount).toList();
+
+ return SliverPadding(
+ padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
+ sliver: SliverLayoutBuilder(
+ builder: (context, constraints) {
+ final layout = _resolveGridLayout(constraints.crossAxisExtent);
+ return SliverGrid.builder(
+ gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: layout.crossAxisCount,
+ mainAxisSpacing: _gridMainAxisSpacing,
+ crossAxisSpacing: _gridCrossAxisSpacing,
+ childAspectRatio: layout.childAspectRatio,
+ ),
+ itemCount: displayItems.length,
+ itemBuilder: (context, index) {
+ final item = displayItems[index];
+ return _TopSiteGridTile(
+ item: item,
+ onTap: () => onUriSelected(item.url),
+ onPin: () => _pinItem(context, ref, item),
+ onEdit: () => _editItem(context, ref, item),
+ onRemove: () => _removeItem(context, ref, item),
+ );
+ },
+ );
+ },
+ ),
+ );
+ }
+}
+
+class _ReorderableTopSitesGrid extends HookConsumerWidget {
+ final List persistedItems;
+ final List historyItems;
+ final ValueNotifier reorderBusy;
+ final void Function(Uri uri) onUriSelected;
+
+ const _ReorderableTopSitesGrid({
+ required this.persistedItems,
+ required this.historyItems,
+ required this.reorderBusy,
+ required this.onUriSelected,
+ });
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final localItems = useState(persistedItems);
+
+ useEffect(() {
+ localItems.value = persistedItems;
+ return null;
+ }, [persistedItems]);
+
+ return SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final layout = _resolveGridLayout(constraints.maxWidth);
+ return ReorderableBuilder.builder(
+ itemCount: localItems.value.length,
+ onReorderPositions: (positions) async {
+ if (reorderBusy.value || positions.isEmpty) return;
+
+ final oldIndex = positions.first.oldIndex;
+ final newIndex = positions.first.newIndex;
+ if (oldIndex < 0 || oldIndex >= localItems.value.length) return;
+ if (newIndex < 0 || newIndex > localItems.value.length) return;
+
+ final items = localItems.value.toList();
+ final movedItem = items.removeAt(oldIndex);
+ if (movedItem.id == null) return;
+
+ final targetIndex = newIndex.clamp(0, items.length);
+
+ // The grid package reports target indices in the final order.
+ items.insert(targetIndex, movedItem);
+ localItems.value = items;
+
+ reorderBusy.value = true;
+ try {
+ final repo = ref.read(topSiteRepositoryProvider.notifier);
+
+ final String key;
+ if (targetIndex <= 0) {
+ key = await repo.getLeadingOrderKey();
+ } else if (targetIndex >= items.length - 1) {
+ key = await repo.getTrailingOrderKey();
+ } else if (targetIndex < oldIndex) {
+ key = (await repo.getOrderKeyAfterSite(
+ items[targetIndex - 1].id!,
+ ))!;
+ } else {
+ key = await repo.getOrderKeyBeforeSite(
+ items[targetIndex + 1].id!,
+ );
+ }
+
+ await repo.assignOrderKey(movedItem.id!, key);
+ } catch (e) {
+ localItems.value = persistedItems;
+ if (context.mounted) {
+ ui_helper.showErrorMessage(
+ context,
+ 'Failed to reorder top site',
+ );
+ }
+ } finally {
+ reorderBusy.value = false;
+ }
+ },
+ childBuilder: (reorderableItemBuilder) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ GridView.builder(
+ padding: EdgeInsets.zero,
+ shrinkWrap: true,
+ physics: const NeverScrollableScrollPhysics(),
+ gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: layout.crossAxisCount,
+ mainAxisSpacing: _gridMainAxisSpacing,
+ crossAxisSpacing: _gridCrossAxisSpacing,
+ childAspectRatio: layout.childAspectRatio,
+ ),
+ itemCount: localItems.value.length,
+ itemBuilder: (context, index) {
+ final item = localItems.value[index];
+ final draggable = CustomDraggable(
+ key: ValueKey(item.id ?? 'top-site-$index'),
+ data: item.id ?? 'top-site-$index',
+ child: _TopSiteGridTile(
+ item: item,
+ onTap: () => onUriSelected(item.url),
+ showDragHandle: true,
+ ),
+ );
+ return reorderableItemBuilder(draggable, index);
+ },
+ ),
+ if (historyItems.isNotEmpty) ...[
+ const SizedBox(height: 8.0),
+ GridView.builder(
+ padding: EdgeInsets.zero,
+ shrinkWrap: true,
+ physics: const NeverScrollableScrollPhysics(),
+ gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: layout.crossAxisCount,
+ mainAxisSpacing: _gridMainAxisSpacing,
+ crossAxisSpacing: _gridCrossAxisSpacing,
+ childAspectRatio: layout.childAspectRatio,
+ ),
+ itemCount: historyItems.length,
+ itemBuilder: (context, index) {
+ final item = historyItems[index];
+ return Opacity(
+ opacity: 0.5,
+ child: _TopSiteGridTile(
+ item: item,
+ onTap: () => onUriSelected(item.url),
+ ),
+ );
+ },
+ ),
+ ],
+ ],
+ );
+ },
+ );
+ },
+ ),
+ ),
+ );
+ }
+}
+
+class _TopSiteGridTile extends StatelessWidget {
+ final TopSiteItem item;
+ final VoidCallback onTap;
+ final VoidCallback? onPin;
+ final VoidCallback? onEdit;
+ final VoidCallback? onRemove;
+ final bool showDragHandle;
+
+ const _TopSiteGridTile({
+ required this.item,
+ required this.onTap,
+ this.onPin,
+ this.onEdit,
+ this.onRemove,
+ this.showDragHandle = false,
+ });
+
+ static const _iconSize = 40.0;
+ static const _borderRadius = BorderRadius.all(Radius.circular(12.0));
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final textTheme = Theme.of(context).textTheme;
+
+ return Material(
+ color: colorScheme.surfaceContainerHigh,
+ borderRadius: _borderRadius,
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ borderRadius: _borderRadius,
+ onTap: onTap,
+ onLongPress: _hasMenu ? () => _showContextMenu(context) : null,
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 8.0,
+ vertical: 10.0,
+ ),
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final titleStyle = textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurface,
+ );
+ final lineHeight =
+ (titleStyle?.fontSize ?? 12.0) *
+ (titleStyle?.height ?? 1.2);
+ const textLines = 2;
+ const gap = 6.0;
+ const minIconSize = 18.0;
+ const textHeightPadding = 2.0;
+ final minTextHeight = lineHeight + textHeightPadding;
+ final maxTextHeight =
+ lineHeight * textLines + textHeightPadding;
+ final iconSize = (constraints.maxHeight - minTextHeight - gap)
+ .clamp(minIconSize, _iconSize);
+
+ return Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Align(
+ child: SizedBox.square(
+ dimension: iconSize,
+ child: RepaintBoundary(
+ child: ClipRRect(
+ borderRadius: const BorderRadius.all(
+ Radius.circular(8.0),
+ ),
+ child: UrlIcon([item.url], iconSize: iconSize),
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(height: gap),
+ Flexible(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(maxHeight: maxTextHeight),
+ child: Text(
+ item.title,
+ maxLines: textLines,
+ overflow: TextOverflow.ellipsis,
+ textAlign: TextAlign.center,
+ style: titleStyle,
+ ),
+ ),
+ ),
+ ],
+ );
+ },
+ ),
+ ),
+ if (item.source == TopSiteSource.pinned)
+ Positioned(
+ top: 4,
+ right: 4,
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: colorScheme.primaryContainer,
+ borderRadius: const BorderRadius.all(Radius.circular(10.0)),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(3.0),
+ child: Icon(
+ Icons.push_pin,
+ size: 12,
+ color: colorScheme.onPrimaryContainer,
+ ),
+ ),
+ ),
+ ),
+ if (showDragHandle)
+ Positioned(
+ top: 2,
+ right: 2,
+ child: Icon(
+ Icons.drag_indicator,
+ size: 16,
+ color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ bool get _hasMenu =>
+ (item.isPersisted && (onEdit != null || onRemove != null)) ||
+ (!item.isPersisted && onPin != null);
+
+ Future _showContextMenu(BuildContext context) async {
+ final RenderBox renderBox = context.findRenderObject()! as RenderBox;
+ final position = renderBox.localToGlobal(Offset.zero);
+ final size = renderBox.size;
+
+ final value = await showMenu(
+ context: context,
+ position: RelativeRect.fromLTRB(
+ position.dx,
+ position.dy + size.height,
+ position.dx + size.width,
+ position.dy + size.height,
+ ),
+ items: [
+ if (!item.isPersisted && onPin != null)
+ const PopupMenuItem(value: 'pin', child: Text('Pin')),
+ if (item.isPersisted && onEdit != null)
+ const PopupMenuItem(value: 'edit', child: Text('Edit')),
+ if (item.isPersisted && onRemove != null)
+ const PopupMenuItem(value: 'remove', child: Text('Remove')),
+ ],
+ );
+
+ switch (value) {
+ case 'pin':
+ onPin?.call();
+ case 'edit':
+ onEdit?.call();
+ case 'remove':
+ onRemove?.call();
+ default:
+ break;
+ }
+ }
+}
+
+Future _pinItem(
+ BuildContext context,
+ WidgetRef ref,
+ TopSiteItem item,
+) async {
+ try {
+ await ref
+ .read(topSiteRepositoryProvider.notifier)
+ .addPinnedSite(title: item.title, url: item.url);
+ if (context.mounted) {
+ ui_helper.showInfoMessage(context, 'Pinned "${item.title}"');
+ }
+ } catch (e) {
+ if (context.mounted) {
+ ui_helper.showErrorMessage(context, 'Failed to pin site');
+ }
+ }
+}
+
+Future _editItem(
+ BuildContext context,
+ WidgetRef ref,
+ TopSiteItem item,
+) async {
+ if (item.id == null) return;
+
+ final result = await showEditTopSiteDialog(
+ context,
+ initialTitle: item.title,
+ initialUrl: item.url,
+ );
+
+ if (result == null || !context.mounted) return;
+
+ try {
+ await ref
+ .read(topSiteRepositoryProvider.notifier)
+ .updatePersistedSite(
+ id: item.id!,
+ title: result.title,
+ url: result.url,
+ );
+ if (context.mounted) {
+ ui_helper.showInfoMessage(context, 'Top site updated');
+ }
+ } catch (e) {
+ if (context.mounted) {
+ ui_helper.showErrorMessage(context, 'Failed to update top site');
+ }
+ }
+}
+
+Future _removeItem(
+ BuildContext context,
+ WidgetRef ref,
+ TopSiteItem item,
+) async {
+ if (item.id == null) return;
+
+ try {
+ await ref
+ .read(topSiteRepositoryProvider.notifier)
+ .removePersistedSite(item.id!);
+ if (context.mounted) {
+ ui_helper.showInfoMessage(
+ context,
+ 'Removed "${item.title}"',
+ action: SnackBarAction(
+ label: 'Undo',
+ onPressed: () async {
+ try {
+ await ref
+ .read(topSiteRepositoryProvider.notifier)
+ .addPinnedSite(title: item.title, url: item.url);
+ } catch (_) {}
+ },
+ ),
+ );
+ }
+ } catch (e) {
+ if (context.mounted) {
+ ui_helper.showErrorMessage(context, 'Failed to remove top site');
+ }
+ }
+}
diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart
index 6f2313ed..a03a9b28 100644
--- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart
+++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart
@@ -30,6 +30,9 @@ class SearchModuleHeader extends StatelessWidget {
final VoidCallback onToggleCollapse;
final VoidCallback onToggleExpansion;
+ /// Optional widget placed between the title area and the trailing button.
+ final Widget? headerTrailing;
+
/// The maximum number of items shown in preview mode.
/// The trailing button is hidden when totalCount <= this value.
final int previewLimit;
@@ -41,6 +44,7 @@ class SearchModuleHeader extends StatelessWidget {
required this.displayState,
required this.onToggleCollapse,
required this.onToggleExpansion,
+ this.headerTrailing,
this.previewLimit = 3,
});
@@ -83,6 +87,7 @@ class SearchModuleHeader extends StatelessWidget {
),
),
),
+ if (headerTrailing != null) headerTrailing!,
if (showTrailing)
TextButton(
onPressed: onToggleExpansion,
diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart
index 085304b1..290b5f49 100644
--- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart
+++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart
@@ -39,6 +39,12 @@ class SearchModuleSection extends ConsumerWidget {
/// [isCollapsed] indicates whether the section is fully collapsed (no items).
/// [visibleCount] is the number of items to display (0 when collapsed,
/// limited in preview, or all when expanded).
+ /// Optional widget placed in the header between title and trailing button.
+ final Widget? headerTrailing;
+
+ /// The maximum number of items shown in preview mode for this section.
+ final int previewLimit;
+
final List Function({
required bool isCollapsed,
required int visibleCount,
@@ -51,6 +57,8 @@ class SearchModuleSection extends ConsumerWidget {
required this.moduleType,
required this.totalCount,
required this.contentSliverBuilder,
+ this.headerTrailing,
+ this.previewLimit = previewItemsPerModule,
});
@override
@@ -62,10 +70,10 @@ class SearchModuleSection extends ConsumerWidget {
final isCollapsed = displayState == SearchModuleDisplayState.collapsed;
final showAllItems =
displayState == SearchModuleDisplayState.expanded ||
- totalCount <= previewItemsPerModule;
+ totalCount <= previewLimit;
final visibleCount = isCollapsed
? 0
- : (showAllItems ? totalCount : previewItemsPerModule);
+ : (showAllItems ? totalCount : previewLimit);
return MultiSliver(
pushPinnedChildren: true,
@@ -78,6 +86,8 @@ class SearchModuleSection extends ConsumerWidget {
title: title,
totalCount: totalCount,
displayState: displayState,
+ headerTrailing: isCollapsed ? null : headerTrailing,
+ previewLimit: previewLimit,
onToggleCollapse: () => ref
.read(
searchModuleDisplayStateControllerProvider(
diff --git a/app/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart b/app/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart
index 28acbf33..759558cd 100644
--- a/app/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart
+++ b/app/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart
@@ -69,12 +69,15 @@ class TopSiteRepository extends _$TopSiteRepository {
Future> getTopSites({int limit = 8}) async {
final persisted = await _getPersistedItems();
+ final targetCount = limit < 0 ? 0 : limit;
- if (persisted.length >= limit) {
- return persisted.take(limit).toList();
+ // Always keep all persisted items. The limit is only used as a history
+ // padding target.
+ if (persisted.length >= targetCount) {
+ return persisted;
}
- final remaining = limit - persisted.length;
+ final remaining = targetCount - persisted.length;
final historyItems = await _getHistoryItems(
limit: remaining,
excludeUrls: persisted.map((s) => s.url.toString()).toSet(),
@@ -89,12 +92,15 @@ class TopSiteRepository extends _$TopSiteRepository {
persistedRows,
) async {
final persistedItems = persistedRows.map(_mapPersistedRow).toList();
+ final targetCount = limit < 0 ? 0 : limit;
- if (persistedItems.length >= limit) {
- return persistedItems.take(limit).toList();
+ // Always keep all persisted items. The limit is only used as a history
+ // padding target.
+ if (persistedItems.length >= targetCount) {
+ return persistedItems;
}
- final remaining = limit - persistedItems.length;
+ final remaining = targetCount - persistedItems.length;
final historyItems = await _getHistoryItems(
limit: remaining,
excludeUrls: persistedItems.map((s) => s.url.toString()).toSet(),
@@ -162,6 +168,35 @@ class TopSiteRepository extends _$TopSiteRepository {
return ref.read(topSiteDatabaseProvider).topSiteDao.deletePersistedSite(id);
}
+ Future getPersistedTopSiteByUrl(Uri url) async {
+ final row = await ref
+ .read(topSiteDatabaseProvider)
+ .topSiteDao
+ .getPersistedTopSiteByUrl(url);
+ return row != null ? _mapPersistedRow(row) : null;
+ }
+
+ Future isPersistedTopSiteUrl(Uri url) async {
+ final row = await ref
+ .read(topSiteDatabaseProvider)
+ .topSiteDao
+ .getPersistedTopSiteByUrl(url);
+ return row != null;
+ }
+
+ Future unpinSiteByUrl(Uri url) async {
+ final row = await ref
+ .read(topSiteDatabaseProvider)
+ .topSiteDao
+ .getPersistedTopSiteByUrl(url);
+ if (row == null) return false;
+ await ref
+ .read(topSiteDatabaseProvider)
+ .topSiteDao
+ .deletePersistedSite(row.id);
+ return true;
+ }
+
Future assignOrderKey(String id, String orderKey) {
return ref
.read(topSiteDatabaseProvider)
diff --git a/app/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.g.dart b/app/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.g.dart
index c4e009ca..3f0154d3 100644
--- a/app/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.g.dart
+++ b/app/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.g.dart
@@ -41,7 +41,7 @@ final class TopSiteRepositoryProvider
}
}
-String _$topSiteRepositoryHash() => r'b70c50a60411cbafb5367cd303d822c2eeb11add';
+String _$topSiteRepositoryHash() => r'b0cd825d479609ff0be3c9cf0f585ab9b4d6d86f';
abstract class _$TopSiteRepository extends $Notifier {
void build();