Skip to content

Commit 4d36841

Browse files
authored
Browser: never focus a URL suggestion by default (#320239)
1 parent e0acc95 commit 4d36841

2 files changed

Lines changed: 80 additions & 27 deletions

File tree

src/vs/workbench/contrib/browserView/electron-browser/widgets/browserUrlBarWidget.ts

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -488,28 +488,25 @@ export class BrowserUrlBarWidget extends Disposable {
488488

489489
let currentValue = picker.value;
490490

491-
// Preserve the user's current selection across re-renders (typing,
492-
// per-provider refresh) by matching the previously active item's id.
493-
// Without this, setting `picker.items` snaps activeItems back to the
494-
// first row, so e.g. opening/closing a tab while the user is
495-
// arrow-keyed onto an open-tab suggestion would yank them back to
496-
// "Go to".
497-
const restoreActiveById = (previousId: string | undefined, items: readonly (IUrlPickerItem | IQuickPickSeparator)[]) => {
498-
if (previousId === undefined) {
499-
return;
500-
}
501-
const match = items.find((i): i is IUrlPickerItem => i.type !== 'separator' && i.id === previousId);
502-
if (match) {
503-
picker.activeItems = [match];
504-
}
505-
};
506-
507491
// Rebuild `picker.items` from the synchronous "Go to" entry plus each
508492
// provider's current cached suggestions, in provider sort order.
509-
const render = () => {
510-
const previousActiveId = picker.activeItems[0]?.id;
511-
const items = this._buildSuggestionItems(currentValue);
512-
const hasGo = items.some(i => i.type !== 'separator');
493+
//
494+
// `preserveSelection` distinguishes the two re-render triggers:
495+
// - User typing (`false`): reset the active item to the default
496+
// "Go to" entry. Starting/continuing a query should always default
497+
// back to "Go to" rather than sticking on a streamed-in suggestion.
498+
// - Background provider refresh (`true`): keep the user's current
499+
// selection (e.g. an arrow-keyed suggestion) so updating one
500+
// provider's results (a tab opening/closing) doesn't yank focus
501+
// back to "Go to".
502+
//
503+
// The active item is set explicitly rather than relying on the quick
504+
// pick's implicit first-row selection, which can otherwise land on a
505+
// suggestion as asynchronous results stream in.
506+
const render = (preserveSelection: boolean) => {
507+
const previousActiveId = preserveSelection ? picker.activeItems[0]?.id : undefined;
508+
const defaultItems = this._buildSuggestionItems(currentValue);
509+
const items: (IUrlPickerItem | IQuickPickSeparator)[] = [...defaultItems];
513510
for (const provider of this._suggestionProviders) {
514511
const state = providerStates.get(provider);
515512
if (!state || state.suggestions.length === 0) {
@@ -531,10 +528,18 @@ export class BrowserUrlBarWidget extends Disposable {
531528
}
532529
}
533530
picker.items = items;
534-
if (!hasGo) {
535-
picker.activeItems = [];
536-
}
537-
restoreActiveById(previousActiveId, items);
531+
532+
// Only the synchronous items from `_buildSuggestionItems` (e.g. the
533+
// "Go to" entry) are eligible for default focus; provider suggestions
534+
// are never auto-focused. Restore the prior selection on a background
535+
// refresh; otherwise (typing, or the prior item disappeared) fall back
536+
// to the first default item, or to nothing when there are none.
537+
const defaultActive = defaultItems.find((i): i is IUrlPickerItem => i.type !== 'separator');
538+
const restored = previousActiveId !== undefined
539+
? items.find((i): i is IUrlPickerItem => i.type !== 'separator' && i.id === previousActiveId)
540+
: undefined;
541+
const active = restored ?? defaultActive;
542+
picker.activeItems = active ? [active] : [];
538543
};
539544

540545
// Re-fetch a single provider against the current value, cancelling
@@ -557,7 +562,7 @@ export class BrowserUrlBarWidget extends Disposable {
557562
return;
558563
}
559564
state.suggestions = results;
560-
render();
565+
render(true);
561566
},
562567
() => { /* keep prior cached suggestions on error */ }
563568
);
@@ -569,7 +574,7 @@ export class BrowserUrlBarWidget extends Disposable {
569574
}
570575
};
571576

572-
render();
577+
render(false);
573578
refreshAllProviders();
574579

575580
// Per-provider state change: refresh only that provider so unrelated
@@ -595,7 +600,7 @@ export class BrowserUrlBarWidget extends Disposable {
595600
}));
596601
disposables.add(picker.onDidChangeValue(value => {
597602
currentValue = value;
598-
render();
603+
render(false);
599604
refreshAllProviders();
600605
// Mirror the picker's typed value into the display continuously,
601606
// running URL renderers so decorations stay live. The picker is

src/vs/workbench/contrib/browserView/test/electron-browser/widgets/browserUrlBarWidget.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,4 +485,52 @@ suite('BrowserUrlBarWidget', () => {
485485
await new Promise(resolve => setTimeout(resolve, 0));
486486
assert.ok(picker.items.some(i => i.type !== 'separator' && i.id === 'sugg-2'), 'refreshed suggestion present');
487487
});
488+
489+
test('streamed-in suggestions are never auto-focused; the default item stays active', async () => {
490+
const { widget, picker } = makeHarness();
491+
mountSuggestionProvider(widget, {
492+
async getSuggestions() {
493+
return [{ id: 'tab-1', label: 'A tab', apply() { } }];
494+
},
495+
});
496+
497+
widget.openUrlPicker();
498+
picker.type('https://typed.test/');
499+
// The synchronous default item ("Go to <value>") is the active item.
500+
assert.strictEqual(picker.activeItems[0]?.id, 'https://typed.test/');
501+
502+
// Once the asynchronous suggestion streams in, focus must NOT jump to it.
503+
await new Promise(resolve => setTimeout(resolve, 0));
504+
assert.ok(picker.items.some(i => i.type !== 'separator' && i.id === 'tab-1'), 'suggestion streamed in');
505+
assert.strictEqual(picker.activeItems[0]?.id, 'https://typed.test/');
506+
});
507+
508+
test('background refresh preserves the user selection but typing resets to the default', async () => {
509+
const { widget, picker } = makeHarness();
510+
const refresh = new Emitter<void>();
511+
store.add(refresh);
512+
mountSuggestionProvider(widget, {
513+
onDidChange: refresh.event,
514+
async getSuggestions() {
515+
return [{ id: 'tab-1', label: 'A tab', apply() { } }];
516+
},
517+
});
518+
519+
widget.openUrlPicker();
520+
picker.type('https://typed.test/');
521+
await new Promise(resolve => setTimeout(resolve, 0));
522+
523+
// User arrow-keys onto the streamed-in suggestion.
524+
const suggestion = picker.items.find((i): i is IQuickPickItem => i.type !== 'separator' && i.id === 'tab-1')!;
525+
picker.activeItems = [suggestion];
526+
527+
// A background provider refresh must keep the user's selection.
528+
refresh.fire();
529+
await new Promise(resolve => setTimeout(resolve, 0));
530+
assert.strictEqual(picker.activeItems[0]?.id, 'tab-1', 'background refresh preserves selection');
531+
532+
// Typing, however, resets focus back to the default "Go to" item.
533+
picker.type('https://typed.test/x');
534+
assert.strictEqual(picker.activeItems[0]?.id, 'https://typed.test/x', 'typing resets to the default item');
535+
});
488536
});

0 commit comments

Comments
 (0)