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