1051 lines
31 KiB
Dart
1051 lines
31 KiB
Dart
/*
|
|
* 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 'package:collection/collection.dart';
|
|
import 'package:fast_equatable/fast_equatable.dart';
|
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
|
import 'package:nullability/nullability.dart';
|
|
import 'package:riverpod/riverpod.dart';
|
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
import 'package:weblibre/core/sort_field.dart';
|
|
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
|
|
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
|
|
import 'package:weblibre/features/bangs/domain/repositories/data.dart';
|
|
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
|
|
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
|
import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart';
|
|
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
|
import 'package:weblibre/features/geckoview/features/browser/domain/entities/tab_view_filter_options.dart';
|
|
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart';
|
|
import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart';
|
|
import 'package:weblibre/features/geckoview/features/search/domain/entities/tab_preview.dart';
|
|
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart';
|
|
import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart';
|
|
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_entity.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';
|
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart';
|
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart';
|
|
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
|
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
|
|
|
part 'providers.g.dart';
|
|
|
|
typedef TabStateWithContainer = (TabState, ContainerData?);
|
|
|
|
@Riverpod()
|
|
bool canManualTabReorder(Ref ref) {
|
|
final filterOptions = ref.watch(tabViewFilterControllerProvider);
|
|
|
|
final hasActiveSearch = ref.watch(
|
|
tabSearchRepositoryProvider(
|
|
TabSearchPartition.preview,
|
|
).select((value) => (value.value?.query ?? '').isNotEmpty),
|
|
);
|
|
|
|
return !filterOptions.hasActiveFilter && !hasActiveSearch;
|
|
}
|
|
|
|
@Riverpod(keepAlive: true)
|
|
class SelectedBangTrigger extends _$SelectedBangTrigger {
|
|
// ignore: document_ignores api decision
|
|
// ignore: use_setters_to_change_properties
|
|
void setTrigger(BangKey trigger) {
|
|
state = trigger;
|
|
}
|
|
|
|
void clearTrigger() {
|
|
state = null;
|
|
}
|
|
|
|
@override
|
|
BangKey? build({String? domain}) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
@Riverpod()
|
|
class SelectedBangData extends _$SelectedBangData {
|
|
@override
|
|
BangData? build({String? domain}) {
|
|
final repository = ref.watch(bangDataRepositoryProvider.notifier);
|
|
final selectedBangTrigger = ref.watch(
|
|
selectedBangTriggerProvider(domain: domain),
|
|
);
|
|
|
|
final subscription = repository.watchBang(selectedBangTrigger).listen((
|
|
value,
|
|
) {
|
|
if (ref.mounted) {
|
|
state = value;
|
|
}
|
|
});
|
|
|
|
ref.onDispose(() async {
|
|
await subscription.cancel();
|
|
});
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
@Riverpod()
|
|
EquatableValue<List<DefaultTabEntity>> containerTabEntities(
|
|
Ref ref,
|
|
ContainerFilter containerFilter,
|
|
) {
|
|
final containerTabs = ref.watch(
|
|
watchContainerTabIdsProvider(
|
|
containerFilter,
|
|
).select((value) => value.value),
|
|
);
|
|
final tabList = ref.watch(tabListProvider);
|
|
final orderKeys = ref.watch(
|
|
watchTabOrderKeysProvider.select((value) => value.value),
|
|
);
|
|
|
|
final availableTabs =
|
|
containerTabs?.where((tabId) => tabList.value.contains(tabId)).toList() ??
|
|
[];
|
|
|
|
switch (containerFilter) {
|
|
case ContainerFilterById():
|
|
return EquatableValue(
|
|
availableTabs
|
|
.map(
|
|
(t) => DefaultTabEntity(
|
|
tabId: t,
|
|
orderKey: orderKeys?[t] ?? '',
|
|
containerId: containerFilter.containerId,
|
|
),
|
|
)
|
|
.toList(),
|
|
);
|
|
case ContainerFilterDisabled():
|
|
return ref.watch(
|
|
watchTabsContainerIdProvider(EquatableValue(availableTabs)).select(
|
|
(value) => EquatableValue(
|
|
value.value?.entries
|
|
.map(
|
|
(e) => DefaultTabEntity(
|
|
tabId: e.key,
|
|
orderKey: orderKeys?[e.key] ?? '',
|
|
containerId: e.value,
|
|
),
|
|
)
|
|
.toList() ??
|
|
[],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
@Riverpod()
|
|
EquatableValue<Map<String, TabState>> containerTabStates(
|
|
Ref ref,
|
|
ContainerFilter containerFilter,
|
|
) {
|
|
final availableTabs = ref.watch(
|
|
containerTabEntitiesProvider(containerFilter),
|
|
);
|
|
final tabStates = ref.watch(tabStatesProvider);
|
|
|
|
return EquatableValue({
|
|
for (final tabEntity in availableTabs.value)
|
|
if (tabStates.containsKey(tabEntity.tabId))
|
|
tabEntity.tabId: tabStates[tabEntity.tabId]!,
|
|
});
|
|
}
|
|
|
|
@Riverpod(keepAlive: true)
|
|
EquatableValue<List<TabStateWithContainer>> fifoTabStates(Ref ref) {
|
|
final containerData = ref
|
|
.watch(watchContainersWithCountProvider.select((value) => value.value))
|
|
.mapNotNull(
|
|
(value) => Map.fromEntries(value.map((c) => MapEntry(c.id, c))),
|
|
);
|
|
|
|
final sortedTabs = ref.watch(
|
|
watchTabsFifoProvider.select((value) => value.value),
|
|
);
|
|
|
|
final tabStates = ref.watch(tabStatesProvider);
|
|
|
|
return EquatableValue([
|
|
if (sortedTabs != null)
|
|
for (final tab in sortedTabs)
|
|
if (tabStates.containsKey(tab.id))
|
|
(
|
|
tabStates[tab.id]!,
|
|
tab.containerId.mapNotNull(
|
|
(containerId) => containerData?[containerId],
|
|
),
|
|
),
|
|
]);
|
|
}
|
|
|
|
@Riverpod()
|
|
EquatableValue<List<TabStateWithContainer>>
|
|
selectedContainerTabStatesWithContainer(Ref ref) {
|
|
final filter = ref.watch(
|
|
selectedContainerProvider.select(
|
|
(value) => ContainerFilterById(containerId: value),
|
|
),
|
|
);
|
|
|
|
final containerData = ref
|
|
.watch(watchContainersWithCountProvider.select((value) => value.value))
|
|
.mapNotNull(
|
|
(value) => Map.fromEntries(value.map((c) => MapEntry(c.id, c))),
|
|
);
|
|
|
|
final sortedTabs = ref.watch(
|
|
containerTabEntitiesProvider(filter).select((value) => value.value),
|
|
);
|
|
|
|
final tabStates = ref.watch(tabStatesProvider);
|
|
|
|
final groupedItems = ref
|
|
.watch(groupedTabListItemsProvider(containerId: filter.containerId))
|
|
.value;
|
|
|
|
final groupedOrder = {
|
|
for (var i = 0; i < groupedItems.length; i++) groupedItems[i].tabId: i,
|
|
};
|
|
|
|
final items = [
|
|
for (final tabEntity in sortedTabs)
|
|
if (tabStates.containsKey(tabEntity.tabId))
|
|
(
|
|
tabStates[tabEntity.tabId]!,
|
|
tabEntity.containerId.mapNotNull(
|
|
(containerId) => containerData?[containerId],
|
|
),
|
|
),
|
|
];
|
|
|
|
items.sort((a, b) {
|
|
final aIndex = groupedOrder[a.$1.id] ?? groupedItems.length;
|
|
final bIndex = groupedOrder[b.$1.id] ?? groupedItems.length;
|
|
return aIndex.compareTo(bIndex);
|
|
});
|
|
|
|
return EquatableValue(items);
|
|
}
|
|
|
|
@Riverpod()
|
|
EquatableValue<List<TabStateWithContainer>> quickTabSwitcherTabStates(
|
|
Ref ref,
|
|
QuickTabSwitcherMode mode,
|
|
) {
|
|
final effectiveMode = ref.watch(
|
|
generalSettingsWithDefaultsProvider.select(
|
|
(settings) => settings.effectiveUiQuickTabSwitcherMode(),
|
|
),
|
|
);
|
|
final selectedTabId = ref.watch(selectedTabProvider);
|
|
|
|
final tabStates = switch (effectiveMode) {
|
|
QuickTabSwitcherMode.lastUsedTabs => ref.watch(fifoTabStatesProvider).value,
|
|
QuickTabSwitcherMode.containerTabs =>
|
|
ref.watch(selectedContainerTabStatesWithContainerProvider).value,
|
|
};
|
|
|
|
// `containerTabs` already had `tabBarDirection` applied during its sort.
|
|
// For `lastUsedTabs` the upstream `fifoTabStates` is MRU-first (timestamp
|
|
// desc); honour the same direction setting here so the user's choice
|
|
// takes effect in the default switcher mode too.
|
|
final tabBarDirection = ref.watch(
|
|
generalSettingsWithDefaultsProvider.select((s) => s.tabBarDirection),
|
|
);
|
|
|
|
return EquatableValue(switch (effectiveMode) {
|
|
QuickTabSwitcherMode.lastUsedTabs => () {
|
|
final filtered = tabStates
|
|
.where((state) => state.$1.id != selectedTabId)
|
|
.toList();
|
|
return tabBarDirection == TabBarDirection.oldestFirst
|
|
? filtered.reversed.toList()
|
|
: filtered;
|
|
}(),
|
|
QuickTabSwitcherMode.containerTabs => tabStates,
|
|
});
|
|
}
|
|
|
|
@Riverpod()
|
|
Future<List<VisitInfo>> quickTabSwitcherHistorySuggestions(
|
|
Ref ref,
|
|
QuickTabSwitcherMode mode,
|
|
) async {
|
|
final effectiveMode = ref.watch(
|
|
generalSettingsWithDefaultsProvider.select(
|
|
(settings) => settings.effectiveUiQuickTabSwitcherMode(),
|
|
),
|
|
);
|
|
final showHistorySuggestions = ref.watch(
|
|
generalSettingsWithDefaultsProvider.select(
|
|
(settings) => settings.quickTabSwitcherShowHistorySuggestions,
|
|
),
|
|
);
|
|
|
|
if (!showHistorySuggestions) {
|
|
return [];
|
|
}
|
|
|
|
final hasTabStates = ref.watch(
|
|
quickTabSwitcherTabStatesProvider(
|
|
effectiveMode,
|
|
).select((value) => value.value.isNotEmpty),
|
|
);
|
|
if (hasTabStates) {
|
|
return [];
|
|
}
|
|
|
|
return ref
|
|
.read(historyRepositoryProvider.notifier)
|
|
.getVisitsPaginated(count: 25);
|
|
}
|
|
|
|
@Riverpod()
|
|
AsyncValue<bool> quickTabSwitcherHasResults(
|
|
Ref ref,
|
|
QuickTabSwitcherMode mode,
|
|
) {
|
|
final effectiveMode = ref.watch(
|
|
generalSettingsWithDefaultsProvider.select(
|
|
(settings) => settings.effectiveUiQuickTabSwitcherMode(),
|
|
),
|
|
);
|
|
final showQuickTabSwitcherBar = ref.watch(
|
|
generalSettingsWithDefaultsProvider.select(
|
|
(settings) => settings.tabBarShowQuickTabSwitcherBar,
|
|
),
|
|
);
|
|
if (!showQuickTabSwitcherBar) {
|
|
return const AsyncValue.data(false);
|
|
}
|
|
|
|
final hasResults = ref.watch(
|
|
quickTabSwitcherTabStatesProvider(
|
|
effectiveMode,
|
|
).select((value) => value.value.isNotEmpty),
|
|
);
|
|
|
|
if (hasResults) {
|
|
return const AsyncValue.data(true);
|
|
}
|
|
|
|
return ref
|
|
.watch(quickTabSwitcherHistorySuggestionsProvider(effectiveMode))
|
|
.whenData((visits) => visits.isNotEmpty);
|
|
}
|
|
|
|
@Riverpod()
|
|
EquatableValue<List<TabEntity>> suggestedTabEntities(
|
|
Ref ref,
|
|
String? containerId,
|
|
) {
|
|
final enableAiFeatures = ref.watch(
|
|
generalSettingsWithDefaultsProvider.select(
|
|
(settings) => settings.enableLocalAiFeatures,
|
|
),
|
|
);
|
|
|
|
if (!enableAiFeatures) {
|
|
return EquatableValue([]);
|
|
}
|
|
|
|
final excludedTabIds = ref.watch(
|
|
watchContainerTabIdsProvider(
|
|
// ignore: provider_parameters
|
|
ContainerFilterById(containerId: containerId),
|
|
).select((value) => EquatableValue(value.value)),
|
|
);
|
|
|
|
final orderKeys = ref.watch(
|
|
watchTabOrderKeysProvider.select((value) => value.value),
|
|
);
|
|
|
|
final suggestions = ref.watch(
|
|
containerTabSuggestionsProvider(containerId).select(
|
|
(value) => EquatableValue(
|
|
value.value.mapNotNull(
|
|
(result) => result
|
|
.whereNot(
|
|
(tabId) => excludedTabIds.value?.contains(tabId) ?? false,
|
|
)
|
|
.map(
|
|
(tabId) => DefaultTabEntity(
|
|
tabId: tabId,
|
|
orderKey: orderKeys?[tabId] ?? '',
|
|
containerId: containerId,
|
|
),
|
|
)
|
|
.toList(),
|
|
) ??
|
|
const [],
|
|
),
|
|
),
|
|
);
|
|
|
|
return suggestions;
|
|
}
|
|
|
|
List<TabEntity> _applyTabFiltersAndSort(
|
|
List<TabEntity> entities,
|
|
TabViewFilterOptions filterOptions,
|
|
Map<String, TabState> tabStates,
|
|
Set<String> pinnedTabIds,
|
|
Map<String, DateTime>? tabTimestamps,
|
|
) {
|
|
final sortField = filterOptions.sortType.sortField;
|
|
final filteredRows = <_TabFilterRow>[];
|
|
var hasPinned = false;
|
|
|
|
for (final entity in entities) {
|
|
final tabState = tabStates[entity.tabId];
|
|
final timestamp = tabTimestamps?[entity.tabId];
|
|
|
|
if (!filterOptions.matchesTab(tabState?.tabMode, timestamp)) {
|
|
continue;
|
|
}
|
|
|
|
final isPinned = pinnedTabIds.contains(entity.tabId);
|
|
hasPinned = hasPinned || isPinned;
|
|
|
|
filteredRows.add(
|
|
_TabFilterRow(
|
|
entity: entity,
|
|
isPinned: isPinned,
|
|
titleKey:
|
|
sortField == SortField.titleAsc || sortField == SortField.titleDesc
|
|
? (tabState?.titleOrAuthority ?? '').toLowerCase()
|
|
: null,
|
|
urlKey: sortField == SortField.urlAsc || sortField == SortField.urlDesc
|
|
? (tabState?.url.toString() ?? '')
|
|
: null,
|
|
dateKey:
|
|
sortField == SortField.dateAsc || sortField == SortField.dateDesc
|
|
? (timestamp ?? DateTime(0))
|
|
: null,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (sortField != null) {
|
|
filteredRows.sort((a, b) {
|
|
if (filterOptions.sortPinnedFirst && a.isPinned != b.isPinned) {
|
|
return b.isPinned ? 1 : -1;
|
|
}
|
|
|
|
final cmp = switch (sortField) {
|
|
SortField.titleAsc => a.titleKey!.compareTo(b.titleKey!),
|
|
SortField.titleDesc => b.titleKey!.compareTo(a.titleKey!),
|
|
SortField.urlAsc => a.urlKey!.compareTo(b.urlKey!),
|
|
SortField.urlDesc => b.urlKey!.compareTo(a.urlKey!),
|
|
SortField.dateAsc => a.dateKey!.compareTo(b.dateKey!),
|
|
SortField.dateDesc => b.dateKey!.compareTo(a.dateKey!),
|
|
};
|
|
|
|
if (cmp == 0) return a.entity.orderKey.compareTo(b.entity.orderKey);
|
|
|
|
return cmp;
|
|
});
|
|
|
|
return filteredRows.map((row) => row.entity).toList();
|
|
}
|
|
|
|
if (!hasPinned || !filterOptions.sortPinnedFirst) {
|
|
return filteredRows.map((row) => row.entity).toList();
|
|
}
|
|
|
|
final pinned = <TabEntity>[];
|
|
final unpinned = <TabEntity>[];
|
|
for (final row in filteredRows) {
|
|
if (row.isPinned) {
|
|
pinned.add(row.entity);
|
|
} else {
|
|
unpinned.add(row.entity);
|
|
}
|
|
}
|
|
|
|
return [...pinned, ...unpinned];
|
|
}
|
|
|
|
class _TabFilterRow {
|
|
final TabEntity entity;
|
|
final bool isPinned;
|
|
final String? titleKey;
|
|
final String? urlKey;
|
|
final DateTime? dateKey;
|
|
|
|
const _TabFilterRow({
|
|
required this.entity,
|
|
required this.isPinned,
|
|
required this.titleKey,
|
|
required this.urlKey,
|
|
required this.dateKey,
|
|
});
|
|
}
|
|
|
|
@Riverpod()
|
|
EquatableValue<List<TabEntity>> seamlessFilteredTabEntities(
|
|
Ref ref, {
|
|
required TabSearchPartition searchPartition,
|
|
required ContainerFilter containerFilter,
|
|
required bool groupTrees,
|
|
}) {
|
|
final orderKeys = ref.watch(
|
|
watchTabOrderKeysProvider.select((value) => value.value),
|
|
);
|
|
|
|
final tabSearchResults = ref
|
|
.watch(
|
|
tabSearchRepositoryProvider(searchPartition).select(
|
|
(value) => EquatableValue(
|
|
value.value.mapNotNull(
|
|
(result) => result.results
|
|
.map(
|
|
(tab) => SearchResultTabEntity(
|
|
tabId: tab.id,
|
|
orderKey: orderKeys?[tab.id] ?? '',
|
|
containerId: tab.containerId,
|
|
searchQuery: result.query,
|
|
),
|
|
)
|
|
.toList(),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
.value;
|
|
|
|
final availableTabs = ref.watch(
|
|
containerTabEntitiesProvider(containerFilter),
|
|
);
|
|
|
|
// Tree mode: no filtering/sorting, return as-is
|
|
if (groupTrees && tabSearchResults == null) {
|
|
final trees = ref.watch(
|
|
watchTabTreesProvider.select(
|
|
(value) => EquatableValue(
|
|
value.value?.map((tree) {
|
|
// Find the container ID for the latest tab
|
|
final containerForTab = availableTabs.value
|
|
.where((t) => t.tabId == tree.latestTabId)
|
|
.firstOrNull
|
|
?.containerId;
|
|
|
|
return TabTreeEntity(
|
|
tabId: tree.latestTabId,
|
|
orderKey: orderKeys?[tree.latestTabId] ?? '',
|
|
containerId: containerForTab,
|
|
rootId: tree.rootTabId,
|
|
totalTabs: tree.totalTabs,
|
|
);
|
|
}).toList() ??
|
|
[],
|
|
),
|
|
),
|
|
);
|
|
|
|
return EquatableValue(
|
|
trees.value
|
|
.where(
|
|
(tree) => availableTabs.value.any(
|
|
(available) => available.tabId == tree.tabId,
|
|
),
|
|
)
|
|
.toList(),
|
|
);
|
|
}
|
|
|
|
final tabStates = ref.watch(tabStatesProvider);
|
|
|
|
final filterOptions = ref.watch(tabViewFilterControllerProvider);
|
|
|
|
final pinnedTabIds = ref.watch(
|
|
watchPinnedTabIdsProvider.select(
|
|
(value) => value.value ?? const <String>{},
|
|
),
|
|
);
|
|
|
|
// Only pull timestamps from DB when date filtering/sorting is active
|
|
final needsTimestamps =
|
|
filterOptions.effectiveDateRange != null ||
|
|
filterOptions.sortType.sortField == SortField.dateAsc ||
|
|
filterOptions.sortType.sortField == SortField.dateDesc;
|
|
final tabTimestamps = needsTimestamps
|
|
? ref.watch(watchTabTimestampsProvider.select((value) => value.value))
|
|
: null;
|
|
|
|
final tabListDirection = ref.watch(
|
|
generalSettingsWithDefaultsProvider.select((s) => s.tabListDirection),
|
|
);
|
|
|
|
// Root tabs are always inserted with a trailing LexoRank key, so the
|
|
// database order (ascending order_key) is oldest-first. Flip to
|
|
// newest-first when the user selects that direction.
|
|
List<TabEntity> applyDirection(List<TabEntity> entities) {
|
|
return tabListDirection == TabListDirection.newestFirst
|
|
? entities.reversed.toList()
|
|
: entities;
|
|
}
|
|
|
|
if (tabSearchResults == null) {
|
|
if (filterOptions.hasActiveFilter || pinnedTabIds.isNotEmpty) {
|
|
return EquatableValue(
|
|
_applyTabFiltersAndSort(
|
|
applyDirection(availableTabs.value),
|
|
filterOptions,
|
|
tabStates,
|
|
pinnedTabIds,
|
|
tabTimestamps,
|
|
),
|
|
);
|
|
}
|
|
|
|
return EquatableValue(applyDirection(availableTabs.value));
|
|
}
|
|
|
|
final searchFiltered = tabSearchResults
|
|
.where(
|
|
(tab) => availableTabs.value.any(
|
|
(available) => available.tabId == tab.tabId,
|
|
),
|
|
)
|
|
.toList();
|
|
|
|
if (filterOptions.hasActiveFilter || pinnedTabIds.isNotEmpty) {
|
|
return EquatableValue(
|
|
_applyTabFiltersAndSort(
|
|
searchFiltered,
|
|
filterOptions,
|
|
tabStates,
|
|
pinnedTabIds,
|
|
tabTimestamps,
|
|
),
|
|
);
|
|
}
|
|
|
|
return EquatableValue(searchFiltered);
|
|
// Search results retain the search-relevance ordering — no direction flip.
|
|
}
|
|
|
|
@Riverpod()
|
|
EquatableValue<List<TabPreview>> filteredTabPreviews(
|
|
Ref ref,
|
|
TabSearchPartition searchPartition,
|
|
ContainerFilter containerFilter,
|
|
) {
|
|
final tabSearchResults = ref
|
|
.watch(
|
|
tabSearchRepositoryProvider(
|
|
searchPartition,
|
|
).select((value) => EquatableValue(value.value)),
|
|
)
|
|
.value;
|
|
|
|
final availableTabStates = ref.watch(
|
|
containerTabStatesProvider(containerFilter),
|
|
);
|
|
|
|
if (tabSearchResults == null) {
|
|
return EquatableValue([]);
|
|
}
|
|
|
|
return EquatableValue(
|
|
tabSearchResults.results
|
|
.where((tab) => availableTabStates.value.containsKey(tab.id))
|
|
.map((tab) {
|
|
final tabState = availableTabStates.value[tab.id]!;
|
|
|
|
return TabPreview(
|
|
id: tab.id,
|
|
containerId: tab.containerId,
|
|
title: tab.title ?? tabState.title,
|
|
icon: tabState.icon,
|
|
url: tab.cleanUrl ?? tabState.url,
|
|
highlightedUrl: tab.url,
|
|
extractedContent: tab.extractedContent,
|
|
fullContent: tab.fullContent,
|
|
sourceSearchQuery: tabSearchResults.query,
|
|
);
|
|
})
|
|
.whereType<TabPreview>()
|
|
.toList(),
|
|
);
|
|
}
|
|
|
|
/// Grouped flat-list rendering for the list and grid views.
|
|
///
|
|
/// Parent rows always render before their descendants. [TabListDirection]
|
|
/// applies both to root group ordering and to sibling ordering below each
|
|
/// parent, so parent-child pairs stay together while child order still follows
|
|
/// the configured direction.
|
|
///
|
|
/// Returns `null` when the input data is not yet available (loading).
|
|
@Riverpod()
|
|
EquatableValue<List<TabListItemEntity>> groupedTabListItems(
|
|
Ref ref, {
|
|
required String? containerId,
|
|
}) {
|
|
final tabsWithRoot = ref.watch(
|
|
watchTabsWithRootAndDepthProvider(
|
|
containerId,
|
|
).select((value) => value.value),
|
|
);
|
|
if (tabsWithRoot == null) {
|
|
return EquatableValue(const []);
|
|
}
|
|
|
|
final tabList = ref.watch(tabListProvider);
|
|
final tabStates = ref.watch(tabStatesProvider);
|
|
final filterOptions = ref.watch(tabViewFilterControllerProvider);
|
|
final pinnedTabIds = ref.watch(
|
|
watchPinnedTabIdsProvider.select(
|
|
(value) => value.value ?? const <String>{},
|
|
),
|
|
);
|
|
final tabListDirection = ref.watch(
|
|
generalSettingsWithDefaultsProvider.select((s) => s.tabListDirection),
|
|
);
|
|
final collapsedGroups = ref.watch(collapsedGroupsProvider);
|
|
|
|
final needsTimestamps =
|
|
filterOptions.effectiveDateRange != null ||
|
|
filterOptions.sortType.sortField == SortField.dateAsc ||
|
|
filterOptions.sortType.sortField == SortField.dateDesc;
|
|
final tabTimestamps = needsTimestamps
|
|
? ref.watch(watchTabTimestampsProvider.select((value) => value.value))
|
|
: null;
|
|
|
|
// Index every row from the CTE — we need the full graph to walk ancestor
|
|
// chains even when intermediate ancestors fail the filter.
|
|
final byId = {for (final row in tabsWithRoot) row.id: row};
|
|
|
|
// Filter to tabs that exist in the engine session list and pass the
|
|
// tab-type / date-range filter. Filtering is applied to *individual* tabs.
|
|
final available = tabsWithRoot
|
|
.where((row) => tabList.value.contains(row.id))
|
|
.where(
|
|
(row) => filterOptions.matchesTab(
|
|
tabStates[row.id]?.tabMode,
|
|
tabTimestamps?[row.id],
|
|
),
|
|
)
|
|
.toList();
|
|
|
|
if (available.isEmpty) {
|
|
return EquatableValue(const []);
|
|
}
|
|
|
|
final visibleIds = {for (final row in available) row.id};
|
|
|
|
// Map every visible row to the closest visible ancestor (its effective
|
|
// root). When the original root is filtered out we walk up the chain via
|
|
// parent_id until we either find a visible ancestor or fall off the top —
|
|
// in the latter case the row becomes its own root. This keeps subtrees
|
|
// grouped under whichever ancestor remains visible.
|
|
String effectiveRootFor(TabsWithRootAndDepthResult row) {
|
|
var effectiveRoot = row;
|
|
var current = row;
|
|
while (true) {
|
|
final parentId = current.parentId;
|
|
if (parentId == null) return effectiveRoot.id;
|
|
final parent = byId[parentId];
|
|
if (parent == null) return effectiveRoot.id;
|
|
if (visibleIds.contains(parent.id)) {
|
|
// Climb to the topmost visible ancestor so siblings collapse into a
|
|
// single group rather than fragmenting.
|
|
effectiveRoot = parent;
|
|
current = parent;
|
|
continue;
|
|
}
|
|
// Skip filtered-out ancestor and keep climbing.
|
|
current = parent;
|
|
}
|
|
}
|
|
|
|
// Recompute depth relative to the effective root (so indentation stays
|
|
// sensible after filtered-out ancestors collapse out).
|
|
int depthFromRoot(TabsWithRootAndDepthResult row, String rootId) {
|
|
var depth = 0;
|
|
var current = row;
|
|
while (current.id != rootId) {
|
|
final parentId = current.parentId;
|
|
if (parentId == null) return depth;
|
|
final parent = byId[parentId];
|
|
if (parent == null) return depth;
|
|
if (visibleIds.contains(parent.id)) {
|
|
depth++;
|
|
}
|
|
current = parent;
|
|
}
|
|
return depth;
|
|
}
|
|
|
|
final byRoot = <String, List<_GroupedRow>>{};
|
|
for (final row in available) {
|
|
final rootId = effectiveRootFor(row);
|
|
byRoot
|
|
.putIfAbsent(rootId, () => [])
|
|
.add(_GroupedRow(row: row, depth: depthFromRoot(row, rootId)));
|
|
}
|
|
|
|
// Build a list of group records to sort across.
|
|
final sortField = filterOptions.sortType.sortField;
|
|
final groupRecords = <_TabGroupRecord>[];
|
|
for (final entry in byRoot.entries) {
|
|
final rootMember = entry.value.firstWhere((r) => r.row.id == entry.key);
|
|
final root = rootMember.row;
|
|
final state = tabStates[root.id];
|
|
final timestamp = tabTimestamps?[root.id];
|
|
groupRecords.add(
|
|
_TabGroupRecord(
|
|
rootId: root.id,
|
|
rootOrderKey: root.orderKey,
|
|
root: rootMember,
|
|
members: entry.value,
|
|
isPinned: pinnedTabIds.contains(root.id),
|
|
titleKey:
|
|
sortField == SortField.titleAsc || sortField == SortField.titleDesc
|
|
? (state?.titleOrAuthority ?? '').toLowerCase()
|
|
: null,
|
|
urlKey: sortField == SortField.urlAsc || sortField == SortField.urlDesc
|
|
? (state?.url.toString() ?? '')
|
|
: null,
|
|
dateKey:
|
|
sortField == SortField.dateAsc || sortField == SortField.dateDesc
|
|
? (timestamp ?? DateTime(0))
|
|
: null,
|
|
),
|
|
);
|
|
}
|
|
|
|
groupRecords.sort((a, b) {
|
|
if (filterOptions.sortPinnedFirst && a.isPinned != b.isPinned) {
|
|
return a.isPinned ? -1 : 1;
|
|
}
|
|
|
|
if (sortField != null) {
|
|
final cmp = switch (sortField) {
|
|
SortField.titleAsc => a.titleKey!.compareTo(b.titleKey!),
|
|
SortField.titleDesc => b.titleKey!.compareTo(a.titleKey!),
|
|
SortField.urlAsc => a.urlKey!.compareTo(b.urlKey!),
|
|
SortField.urlDesc => b.urlKey!.compareTo(a.urlKey!),
|
|
SortField.dateAsc => a.dateKey!.compareTo(b.dateKey!),
|
|
SortField.dateDesc => b.dateKey!.compareTo(a.dateKey!),
|
|
};
|
|
if (cmp != 0) return cmp;
|
|
}
|
|
|
|
return a.rootOrderKey.compareTo(b.rootOrderKey);
|
|
});
|
|
|
|
// Direction applies to the order of root groups here. Sibling order below
|
|
// each parent is handled during recursive flattening.
|
|
// When no explicit sortField is active, oldest-first is the natural
|
|
// order_key ASC; newest-first reverses the group list. Pinned and
|
|
// unpinned partitions are reversed independently so pinned-first stays
|
|
// intact while still flipping the relative order within each partition.
|
|
if (sortField == null && tabListDirection == TabListDirection.newestFirst) {
|
|
if (filterOptions.sortPinnedFirst) {
|
|
final pinned = groupRecords.where((g) => g.isPinned).toList()
|
|
..sort((a, b) => b.rootOrderKey.compareTo(a.rootOrderKey));
|
|
final unpinned = groupRecords.where((g) => !g.isPinned).toList()
|
|
..sort((a, b) => b.rootOrderKey.compareTo(a.rootOrderKey));
|
|
groupRecords
|
|
..clear()
|
|
..addAll(pinned)
|
|
..addAll(unpinned);
|
|
} else {
|
|
final reversed = groupRecords.reversed.toList();
|
|
groupRecords
|
|
..clear()
|
|
..addAll(reversed);
|
|
}
|
|
}
|
|
|
|
// Flatten according to expansion state.
|
|
final result = <TabListItemEntity>[];
|
|
for (final group in groupRecords) {
|
|
if (group.members.length == 1) {
|
|
final only = group.root.row;
|
|
result.add(
|
|
TabListStandaloneItem(
|
|
tabId: only.id,
|
|
orderKey: only.orderKey,
|
|
containerId: containerId,
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
final root = group.root.row;
|
|
result.add(
|
|
TabListParentGroup(
|
|
tabId: root.id,
|
|
orderKey: root.orderKey,
|
|
containerId: containerId,
|
|
childCount: group.members.length - 1,
|
|
),
|
|
);
|
|
|
|
if (collapsedGroups.contains(root.id)) {
|
|
continue;
|
|
}
|
|
|
|
final childrenByVisibleParent = <String, List<_GroupedRow>>{};
|
|
for (final member in group.members) {
|
|
if (member.row.id == root.id) {
|
|
continue;
|
|
}
|
|
|
|
final visibleParentId = _nearestVisibleParentId(
|
|
member.row,
|
|
root.id,
|
|
byId,
|
|
visibleIds,
|
|
);
|
|
childrenByVisibleParent
|
|
.putIfAbsent(visibleParentId, () => [])
|
|
.add(member);
|
|
}
|
|
|
|
// Children are always sorted by storage `order_key` (optionally reversed
|
|
// for newest-first) — even when an explicit `sortField` (title/url/date)
|
|
// is active. The explicit sort applies only to root groups; descendants
|
|
// remain in insertion order so a parent's children stay grouped in the
|
|
// order they were opened rather than alphabetically interleaving across
|
|
// siblings.
|
|
for (final children in childrenByVisibleParent.values) {
|
|
children.sort((a, b) {
|
|
final cmp = a.row.orderKey.compareTo(b.row.orderKey);
|
|
return tabListDirection == TabListDirection.newestFirst ? -cmp : cmp;
|
|
});
|
|
}
|
|
|
|
void addChildren(String parentId) {
|
|
for (final member
|
|
in childrenByVisibleParent[parentId] ?? const <_GroupedRow>[]) {
|
|
final child = member.row;
|
|
final grandchildren =
|
|
childrenByVisibleParent[child.id] ?? const <_GroupedRow>[];
|
|
result.add(
|
|
TabListChildItem(
|
|
tabId: child.id,
|
|
orderKey: child.orderKey,
|
|
containerId: containerId,
|
|
parentId: parentId,
|
|
rootId: root.id,
|
|
depth: member.depth,
|
|
childCount: grandchildren.length,
|
|
),
|
|
);
|
|
// Respect per-node collapse: collapsing an intermediate child hides
|
|
// its descendants while keeping siblings of the parent visible.
|
|
if (!collapsedGroups.contains(child.id)) {
|
|
addChildren(child.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
addChildren(root.id);
|
|
}
|
|
|
|
return EquatableValue(result);
|
|
}
|
|
|
|
String _nearestVisibleParentId(
|
|
TabsWithRootAndDepthResult row,
|
|
String rootId,
|
|
Map<String, TabsWithRootAndDepthResult> byId,
|
|
Set<String> visibleIds,
|
|
) {
|
|
var current = row;
|
|
final seen = <String>{row.id};
|
|
|
|
while (true) {
|
|
final parentId = current.parentId;
|
|
if (parentId == null) {
|
|
return rootId;
|
|
}
|
|
if (parentId == rootId) {
|
|
return rootId;
|
|
}
|
|
|
|
final parent = byId[parentId];
|
|
if (parent == null || !seen.add(parent.id)) {
|
|
return rootId;
|
|
}
|
|
if (visibleIds.contains(parent.id)) {
|
|
return parent.id;
|
|
}
|
|
|
|
current = parent;
|
|
}
|
|
}
|
|
|
|
class _GroupedRow {
|
|
final TabsWithRootAndDepthResult row;
|
|
final int depth;
|
|
|
|
_GroupedRow({required this.row, required this.depth});
|
|
}
|
|
|
|
class _TabGroupRecord {
|
|
final String rootId;
|
|
final String rootOrderKey;
|
|
final _GroupedRow root;
|
|
final List<_GroupedRow> members;
|
|
final bool isPinned;
|
|
final String? titleKey;
|
|
final String? urlKey;
|
|
final DateTime? dateKey;
|
|
|
|
_TabGroupRecord({
|
|
required this.rootId,
|
|
required this.rootOrderKey,
|
|
required this.root,
|
|
required this.members,
|
|
required this.isPinned,
|
|
required this.titleKey,
|
|
required this.urlKey,
|
|
required this.dateKey,
|
|
});
|
|
}
|
|
|
|
@Riverpod()
|
|
class AppLinksModeNotifier extends _$AppLinksModeNotifier {
|
|
final _service = GeckoEngineSettingsService();
|
|
|
|
Future<void> setMode(AppLinksMode mode) async {
|
|
await _service.setAppLinksMode(mode);
|
|
ref.invalidateSelf();
|
|
}
|
|
|
|
@override
|
|
Future<AppLinksMode> build() {
|
|
return _service.getAppLinksMode();
|
|
}
|
|
}
|