This commit is contained in:
Fabian Freund
2026-06-12 06:00:10 +02:00
parent 2d13c1b065
commit 0540618bbf
19 changed files with 100 additions and 142 deletions
@@ -145,8 +145,10 @@ class BangUrlPattern {
if (value == null) return null; if (value == null) return null;
if (!value.startsWith(prefix)) return null; if (!value.startsWith(prefix)) return null;
if (!value.endsWith(suffix)) return null; if (!value.endsWith(suffix)) return null;
final captured = final captured = value.substring(
value.substring(prefix.length, value.length - suffix.length); prefix.length,
value.length - suffix.length,
);
return captured.isEmpty ? null : captured; return captured.isEmpty ? null : captured;
} }
@@ -155,8 +157,10 @@ class BangUrlPattern {
final segment = segments[pathIndex!]; final segment = segments[pathIndex!];
if (!segment.startsWith(prefix)) return null; if (!segment.startsWith(prefix)) return null;
if (!segment.endsWith(suffix)) return null; if (!segment.endsWith(suffix)) return null;
final captured = final captured = segment.substring(
segment.substring(prefix.length, segment.length - suffix.length); prefix.length,
segment.length - suffix.length,
);
return captured.isEmpty ? null : captured; return captured.isEmpty ? null : captured;
} }
@@ -238,8 +242,9 @@ class BangUrlPattern {
final placeholderValue = scopeComponents.queryParams[sentinelParam]!; final placeholderValue = scopeComponents.queryParams[sentinelParam]!;
final sentinelStart = placeholderValue.indexOf(_sentinel); final sentinelStart = placeholderValue.indexOf(_sentinel);
final prefix = placeholderValue.substring(0, sentinelStart); final prefix = placeholderValue.substring(0, sentinelStart);
final suffix = final suffix = placeholderValue.substring(
placeholderValue.substring(sentinelStart + _sentinel.length); sentinelStart + _sentinel.length,
);
// The other params in the same scope become required constants. // The other params in the same scope become required constants.
final required = <String, String>{ final required = <String, String>{
@@ -327,8 +332,9 @@ bool _requiredQueryParamsMatch(
/// duplicate-keyed query params (multi-map semantics aren't worth the /// duplicate-keyed query params (multi-map semantics aren't worth the
/// complexity for our use case). /// complexity for our use case).
_Components? _componentsFromUri(Uri uri) { _Components? _componentsFromUri(Uri uri) {
final segments = final segments = uri.pathSegments
uri.pathSegments.where((s) => s.isNotEmpty).toList(growable: false); .where((s) => s.isNotEmpty)
.toList(growable: false);
final all = uri.queryParametersAll; final all = uri.queryParametersAll;
final params = <String, String>{}; final params = <String, String>{};
for (final entry in all.entries) { for (final entry in all.entries) {
@@ -64,14 +64,11 @@ class DesktopMode extends _$DesktopMode {
// landing on a ruled host forces desktop on again, and leaving it reverts // landing on a ruled host forces desktop on again, and leaving it reverts
// to the browser-wide default. Watching only the host keeps in-page // to the browser-wide default. Watching only the host keeps in-page
// navigations (path/query changes) from clobbering a manual override. // navigations (path/query changes) from clobbering a manual override.
ref.listen( ref.listen(tabStateProvider(tabId), (previous, next) {
tabStateProvider(tabId),
(previous, next) {
if (previous?.url.host != next?.url.host) { if (previous?.url.host != next?.url.host) {
state = _resolveForHost(next?.url); state = _resolveForHost(next?.url);
} }
}, });
);
// Seed the initial value from the per-site rule (falling back to the // Seed the initial value from the per-site rule (falling back to the
// browser-wide default) so a newly opened tab's menu checkbox matches the // browser-wide default) so a newly opened tab's menu checkbox matches the
@@ -168,7 +168,8 @@ buildQuickTabSwitcherChipDecoration(
null => null, null => null,
}, },
side: (item, isSelected) => switch (item.color) { side: (item, isSelected) => switch (item.color) {
final color? when isSelected => thickContainerSelectedBorder final color? when isSelected =>
thickContainerSelectedBorder
? BorderSide( ? BorderSide(
color: ContainerColors.palette( color: ContainerColors.palette(
context, context,
@@ -90,8 +90,9 @@ class GestureExclusionSection extends HookConsumerWidget {
bool enabled, bool enabled,
) async { ) async {
try { try {
await ref.read(gestureSettingsRepositoryProvider.notifier).updateSettings( await ref.read(gestureSettingsRepositoryProvider.notifier).updateSettings((
(current) { current,
) {
final next = current.excludedSites.toList(); final next = current.excludedSites.toList();
if (enabled) { if (enabled) {
// Enabling gestures here => remove the host from the exclusion list. // Enabling gestures here => remove the host from the exclusion list.
@@ -101,8 +102,7 @@ class GestureExclusionSection extends HookConsumerWidget {
next.add(host); next.add(host);
} }
return current.copyWith.excludedSites(next); return current.copyWith.excludedSites(next);
}, });
);
} catch (e, s) { } catch (e, s) {
logger.e('Failed to toggle gesture exclusion', error: e, stackTrace: s); logger.e('Failed to toggle gesture exclusion', error: e, stackTrace: s);
if (context.mounted) { if (context.mounted) {
@@ -136,9 +136,7 @@ class _TabParentPickerSheet extends HookConsumerWidget {
child: ListTile( child: ListTile(
leading: const Icon(MdiIcons.fileTreeOutline), leading: const Icon(MdiIcons.fileTreeOutline),
title: const Text('Make standalone'), title: const Text('Make standalone'),
subtitle: const Text( subtitle: const Text('Detach from current parent'),
'Detach from current parent',
),
enabled: movingTab.parentId != null, enabled: movingTab.parentId != null,
onTap: () => Navigator.of( onTap: () => Navigator.of(
context, context,
@@ -173,8 +171,7 @@ class _TabParentPickerSheet extends HookConsumerWidget {
// the one you're currently nested under". // the one you're currently nested under".
return ListTabPreview( return ListTabPreview(
tabId: candidate.id, tabId: candidate.id,
isActive: isActive: candidate.id == movingTab.parentId,
candidate.id == movingTab.parentId,
onTap: () => Navigator.of( onTap: () => Navigator.of(
context, context,
).pop(_ParentPickerSelected(candidate.id)), ).pop(_ParentPickerSelected(candidate.id)),
@@ -296,8 +296,7 @@ class _TabTreesGrid extends HookConsumerWidget {
final row = index ~/ 2; final row = index ~/ 2;
final tabStart = row * itemSize.height; final tabStart = row * itemSize.height;
final viewportDimension = final viewportDimension = scrollController.position.viewportDimension;
scrollController.position.viewportDimension;
final targetOffset = final targetOffset =
(tabStart - viewportDimension / 2 + itemSize.height / 2).clamp( (tabStart - viewportDimension / 2 + itemSize.height / 2).clamp(
@@ -324,17 +323,14 @@ class _TabTreesGrid extends HookConsumerWidget {
}, [filteredTabEntities, activeTab]); }, [filteredTabEntities, activeTab]);
final tabs = useMemoized(() { final tabs = useMemoized(() {
return filteredTabEntities.value return filteredTabEntities.value.whereType<TabTreeEntity>().map((entity) {
.whereType<TabTreeEntity>()
.map((entity) {
return _TabTreePreview( return _TabTreePreview(
entity: entity, entity: entity,
activeTabId: activeTab, activeTabId: activeTab,
onClose: onClose, onClose: onClose,
stackPadding: const Offset(8, 8), stackPadding: const Offset(8, 8),
); );
}) }).toList();
.toList();
}, [filteredTabEntities, activeTab]); }, [filteredTabEntities, activeTab]);
return Padding( return Padding(
@@ -143,9 +143,7 @@ class OpenSharedContent extends HookConsumerWidget {
unawaited( unawaited(
Future(() async { Future(() async {
ContainerData? resolved; ContainerData? resolved;
final containerRepo = ref.read( final containerRepo = ref.read(containerRepositoryProvider.notifier);
containerRepositoryProvider.notifier,
);
// Priority: explicit intent container (PWA shortcut) > site // Priority: explicit intent container (PWA shortcut) > site
// assignment for the URL > mode default. // assignment for the URL > mode default.
@@ -160,9 +158,7 @@ class OpenSharedContent extends HookConsumerWidget {
final siteAssignedId = await containerRepo final siteAssignedId = await containerRepo
.siteAssignedContainerId(Uri.parse(selectionUrlKey)); .siteAssignedContainerId(Uri.parse(selectionUrlKey));
if (siteAssignedId != null) { if (siteAssignedId != null) {
resolved = await containerRepo.getContainerData( resolved = await containerRepo.getContainerData(siteAssignedId);
siteAssignedId,
);
} }
} }
@@ -631,8 +631,7 @@ class SearchScreen extends HookConsumerWidget {
// Whether to surface an in-app close button so the page can be dismissed // Whether to surface an in-app close button so the page can be dismissed
// without a system back button/gesture (opt-in, e.g. for e-ink devices). // without a system back button/gesture (opt-in, e.g. for e-ink devices).
// Only meaningful when there is a route to pop back to. // Only meaningful when there is a route to pop back to.
final showCloseButton = final showCloseButton = context.canPop() && settings.showSearchCloseButton;
context.canPop() && settings.showSearchCloseButton;
return Scaffold( return Scaffold(
body: SafeArea( body: SafeArea(
@@ -62,10 +62,7 @@ class ContainerDraftSuggestionsScreen extends HookConsumerWidget {
); );
return math.max( return math.max(
math.min( math.min(calculatedCount, selectedContainer.value?.tabIds.length ?? 0),
calculatedCount,
selectedContainer.value?.tabIds.length ?? 0,
),
2, 2,
); );
}, [screenWidth, selectedContainer.value?.tabIds.length]); }, [screenWidth, selectedContainer.value?.tabIds.length]);
@@ -77,10 +77,10 @@ class ColorPickerDialog extends HookWidget {
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
TextButton( TextButton(
onPressed: () => Navigator.pop<ColorPickerResult?>( onPressed: () => Navigator.pop<ColorPickerResult?>(context, (
context, color: selectedColor.value,
(color: selectedColor.value, useCustomColor: useCustom.value), useCustomColor: useCustom.value,
), )),
child: const Text('Select'), child: const Text('Select'),
), ),
], ],
@@ -191,11 +191,7 @@ class _CustomSwatch extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final palette = isSelected final palette = isSelected
? ContainerColors.palette( ? ContainerColors.palette(context, selectedColor, useCustomColor: true)
context,
selectedColor,
useCustomColor: true,
)
: null; : null;
return InkResponse( return InkResponse(
onTap: onTap, onTap: onTap,
@@ -220,7 +220,9 @@ class _CooldownSection extends HookConsumerWidget {
value: settings.intervalMs value: settings.intervalMs
.clamp(minGestureIntervalMs, maxGestureIntervalMs) .clamp(minGestureIntervalMs, maxGestureIntervalMs)
.toDouble(), .toDouble(),
label: settings.intervalMs == 0 ? 'Off' : '${settings.intervalMs} ms', label: settings.intervalMs == 0
? 'Off'
: '${settings.intervalMs} ms',
onChanged: (value) async { onChanged: (value) async {
await ref await ref
.read(gestureSettingsRepositoryProvider.notifier) .read(gestureSettingsRepositoryProvider.notifier)
@@ -141,8 +141,9 @@ class _SuggestAfterSection extends HookConsumerWidget {
await ref await ref
.read(gestureSettingsRepositoryProvider.notifier) .read(gestureSettingsRepositoryProvider.notifier)
.updateSettings( .updateSettings(
(current) => (current) => current.copyWith.minSuggestionStroke(
current.copyWith.minSuggestionStroke(value.round()), value.round(),
),
); );
} }
: null, : null,
@@ -78,10 +78,7 @@ class _GestureActionPicker extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Align( child: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text( child: Text('Choose action', style: theme.textTheme.titleLarge),
'Choose action',
style: theme.textTheme.titleLarge,
),
), ),
), ),
Expanded( Expanded(
@@ -65,11 +65,7 @@ class GestureStrokeView extends StatelessWidget {
if (showQualifiers) ...[ if (showQualifiers) ...[
Padding( Padding(
padding: const EdgeInsets.only(right: 6), padding: const EdgeInsets.only(right: 6),
child: Icon( child: Icon(stroke.startPosition.icon, size: 18, color: foreground),
stroke.startPosition.icon,
size: 18,
color: foreground,
),
), ),
Padding( Padding(
padding: const EdgeInsets.only(right: 6), padding: const EdgeInsets.only(right: 6),
@@ -334,9 +334,7 @@ class _TabBarStackingModeSection extends HookConsumerWidget {
children: [ children: [
const ListTile( const ListTile(
title: Text('Tab Stacking'), title: Text('Tab Stacking'),
subtitle: Text( subtitle: Text('How the quick tab switcher bar arranges its tabs'),
'How the quick tab switcher bar arranges its tabs',
),
leading: Icon(MdiIcons.folderSettings), leading: Icon(MdiIcons.folderSettings),
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
), ),
@@ -505,8 +503,7 @@ class _QuickTabSwitcherTitleWidthTile extends HookConsumerWidget {
onChangeEnd: enabled onChangeEnd: enabled
? (value) async { ? (value) async {
final normalized = final normalized =
(value / quickTabSwitcherTitleWidthStep) (value / quickTabSwitcherTitleWidthStep).round() *
.round() *
quickTabSwitcherTitleWidthStep; quickTabSwitcherTitleWidthStep;
sliderValue.value = normalized; sliderValue.value = normalized;
await ref await ref
@@ -683,7 +680,9 @@ class _QuickTabSwitcherHierarchyGlyphsTile extends HookConsumerWidget {
) )
.save( .save(
(currentSettings) => currentSettings.copyWith (currentSettings) => currentSettings.copyWith
.quickTabSwitcherHierarchyGlyphs(normalized), .quickTabSwitcherHierarchyGlyphs(
normalized,
),
); );
} }
: null, : null,
@@ -64,8 +64,7 @@ class TabBarPreviewHeaderDelegate extends SliverPersistentHeaderDelegate {
_ => 1, _ => 1,
}; };
return height + return height + BrowserTabBar.quickTabSwitcherHeight * quickTabSwitcherRows;
BrowserTabBar.quickTabSwitcherHeight * quickTabSwitcherRows;
} }
double get _baseHeight => double get _baseHeight =>
@@ -58,7 +58,9 @@ class WebSearchInfoboxCard extends HookConsumerWidget {
void toggle() { void toggle() {
ref ref
.read(persistedBoolProvider(PersistedBoolKey.infoboxExpanded).notifier) .read(
persistedBoolProvider(PersistedBoolKey.infoboxExpanded).notifier,
)
.toggle(); .toggle();
} }
@@ -347,7 +349,10 @@ class _InfoboxLinks extends StatelessWidget {
), ),
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: colorScheme.primary, foregroundColor: colorScheme.primary,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
), ),
), ),
), ),
@@ -59,9 +59,7 @@ void main() {
}); });
test('path segment: captures search term', () { test('path segment: captures search term', () {
final p = BangUrlPattern.parse( final p = BangUrlPattern.parse('https://en.wikipedia.org/wiki/{{{s}}}');
'https://en.wikipedia.org/wiki/{{{s}}}',
);
expect( expect(
p!.match(Uri.parse('https://en.wikipedia.org/wiki/Flutter')), p!.match(Uri.parse('https://en.wikipedia.org/wiki/Flutter')),
'Flutter', 'Flutter',
@@ -69,9 +67,7 @@ void main() {
}); });
test('path segment: enforces non-placeholder segments', () { test('path segment: enforces non-placeholder segments', () {
final p = BangUrlPattern.parse( final p = BangUrlPattern.parse('https://www.ebay.com/sch/{{{s}}}/m.html');
'https://www.ebay.com/sch/{{{s}}}/m.html',
);
expect( expect(
p!.match(Uri.parse('https://www.ebay.com/sch/toys/m.html')), p!.match(Uri.parse('https://www.ebay.com/sch/toys/m.html')),
'toys', 'toys',
@@ -90,26 +86,17 @@ void main() {
p!.match(Uri.parse('https://site.com/find/prefix-Foo-end')), p!.match(Uri.parse('https://site.com/find/prefix-Foo-end')),
'Foo', 'Foo',
); );
expect( expect(p.match(Uri.parse('https://site.com/find/Foo-end')), isNull);
p.match(Uri.parse('https://site.com/find/Foo-end')),
isNull,
);
}); });
test('host mismatch rejects', () { test('host mismatch rejects', () {
final p = BangUrlPattern.parse('https://google.com/search?q={{{s}}}'); final p = BangUrlPattern.parse('https://google.com/search?q={{{s}}}');
expect( expect(p!.match(Uri.parse('https://bing.com/search?q=foo')), isNull);
p!.match(Uri.parse('https://bing.com/search?q=foo')),
isNull,
);
}); });
test('path length mismatch rejects', () { test('path length mismatch rejects', () {
final p = BangUrlPattern.parse('https://x.com/a?q={{{s}}}'); final p = BangUrlPattern.parse('https://x.com/a?q={{{s}}}');
expect( expect(p!.match(Uri.parse('https://x.com/a/b?q=foo')), isNull);
p!.match(Uri.parse('https://x.com/a/b?q=foo')),
isNull,
);
}); });
test('empty captured query is rejected', () { test('empty captured query is rejected', () {
@@ -152,7 +139,9 @@ void main() {
}); });
test('non-generic subdomains still must match', () { test('non-generic subdomains still must match', () {
final p = BangUrlPattern.parse('https://cn.bing.com/dict/search?q={{{s}}}'); final p = BangUrlPattern.parse(
'https://cn.bing.com/dict/search?q={{{s}}}',
);
expect( expect(
p!.match(Uri.parse('https://www.bing.com/dict/search?q=foo')), p!.match(Uri.parse('https://www.bing.com/dict/search?q=foo')),
isNull, isNull,
@@ -178,9 +167,7 @@ void main() {
); );
expect(p, isNotNull); expect(p, isNotNull);
expect( expect(
p!.match( p!.match(Uri.parse('https://research.lensai.eu/#/s/search/quantum')),
Uri.parse('https://research.lensai.eu/#/s/search/quantum'),
),
'quantum', 'quantum',
); );
}); });
@@ -190,31 +177,21 @@ void main() {
'https://research.lensai.eu/#/s/search/{{{s}}}', 'https://research.lensai.eu/#/s/search/{{{s}}}',
); );
expect( expect(
p!.match( p!.match(Uri.parse('https://research.lensai.eu/#/s/answer/quantum')),
Uri.parse('https://research.lensai.eu/#/s/answer/quantum'),
),
isNull, isNull,
); );
}); });
test('hash query: tolerates extra fragment params', () { test('hash query: tolerates extra fragment params', () {
final p = BangUrlPattern.parse( final p = BangUrlPattern.parse('https://site.example/#s={{{s}}}');
'https://site.example/#s={{{s}}}', expect(p!.match(Uri.parse('https://site.example/#s=foo&page=2')), 'foo');
);
expect(
p!.match(Uri.parse('https://site.example/#s=foo&page=2')),
'foo',
);
}); });
test('fragment-required template rejects URLs without fragment', () { test('fragment-required template rejects URLs without fragment', () {
final p = BangUrlPattern.parse( final p = BangUrlPattern.parse(
'https://boards.4chan.org/g/catalog#s={{{s}}}', 'https://boards.4chan.org/g/catalog#s={{{s}}}',
); );
expect( expect(p!.match(Uri.parse('https://boards.4chan.org/g/catalog')), isNull);
p!.match(Uri.parse('https://boards.4chan.org/g/catalog')),
isNull,
);
}); });
}); });
@@ -238,9 +215,7 @@ void main() {
group('BangUrlPattern - tie-break helpers', () { group('BangUrlPattern - tie-break helpers', () {
test('more required constants score higher', () { test('more required constants score higher', () {
final plain = BangUrlPattern.parse('https://x.com/?q={{{s}}}')!; final plain = BangUrlPattern.parse('https://x.com/?q={{{s}}}')!;
final imgs = BangUrlPattern.parse( final imgs = BangUrlPattern.parse('https://x.com/?q={{{s}}}&tbm=isch')!;
'https://x.com/?q={{{s}}}&tbm=isch',
)!;
expect(imgs.constraintCount, greaterThan(plain.constraintCount)); expect(imgs.constraintCount, greaterThan(plain.constraintCount));
}); });
}); });
@@ -60,8 +60,7 @@ class GeckoEventService extends GeckoStateEvents {
ValueStream<bool> get engineReadyStateEvents => _engineStateSubject.stream; ValueStream<bool> get engineReadyStateEvents => _engineStateSubject.stream;
ValueStream<List<String>> get tabListEvents => _tabListSubject.stream; ValueStream<List<String>> get tabListEvents => _tabListSubject.stream;
ValueStream<String?> get selectedTabEvents => _selectedTabSubject.stream; ValueStream<String?> get selectedTabEvents => _selectedTabSubject.stream;
ValueStream<bool> get restoreCompleteEvents => ValueStream<bool> get restoreCompleteEvents => _restoreCompleteSubject.stream;
_restoreCompleteSubject.stream;
Stream<TabContentState> get tabContentEvents => _tabContentSubject.stream; Stream<TabContentState> get tabContentEvents => _tabContentSubject.stream;
Stream<HistoryEvent> get historyEvents => _historySubject.stream; Stream<HistoryEvent> get historyEvents => _historySubject.stream;