Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { localize, localize2 } from '../../../../../nls.js';
7
import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js';
8
import { ServicesAccessor, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
9
import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js';
10
import { KeyMod, KeyCode } from '../../../../../base/common/keyCodes.js';
11
import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../services/editor/common/editorService.js';
12
import { IEditorGroupsService, GroupsOrder } from '../../../../services/editor/common/editorGroupsService.js';
13
import { EditorsOrder, GroupIdentifier } from '../../../../common/editor.js';
14
import { IQuickInputService, IQuickInputButton, IQuickPickItem, IQuickPickSeparator, QuickInputButtonLocation, IQuickPick } from '../../../../../platform/quickinput/common/quickInput.js';
15
import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js';
16
import { URI } from '../../../../../base/common/uri.js';
17
import { Codicon } from '../../../../../base/common/codicons.js';
18
import { ThemeIcon } from '../../../../../base/common/themables.js';
19
import { BrowserViewUri } from '../../../../../platform/browserView/common/browserViewUri.js';
20
import { generateUuid } from '../../../../../base/common/uuid.js';
21
import { BrowserEditorInput } from '../../common/browserEditorInput.js';
22
import { BROWSER_EDITOR_ACTIVE, BrowserActionCategory, BrowserActionGroup } from '../browserViewActions.js';
23
import { logBrowserOpen } from '../../../../../platform/browserView/common/browserViewTelemetry.js';
24
import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';
25
import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js';
26
import { BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js';
27
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js';
28
import { IBrowserViewModel, IBrowserViewWorkbenchService } from '../../common/browserView.js';
29
import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../../platform/configuration/common/configurationRegistry.js';
30
import { workbenchConfigurationNodeBase } from '../../../../common/configuration.js';
31
import { IExternalOpener, IOpenerService } from '../../../../../platform/opener/common/opener.js';
32
import { isLocalhostAuthority } from '../../../../../platform/url/common/trustedDomains.js';
33
import { IConfigurationService, isConfigured } from '../../../../../platform/configuration/common/configuration.js';
34
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
35
import { CancellationToken } from '../../../../../base/common/cancellation.js';
36
import { ToggleTitleBarConfigAction } from '../../../../browser/parts/titlebar/titlebarActions.js';
37
import { Registry } from '../../../../../platform/registry/common/platform.js';
38
import { match } from '../../../../../base/common/glob.js';
39
import { $, addDisposableListener, EventType } from '../../../../../base/browser/dom.js';
40
import { BrowserEditor, BrowserEditorContribution, IBrowserEditorWidgetContribution } from '../browserEditor.js';
41
import { IHoverService } from '../../../../../platform/hover/browser/hover.js';
42
import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js';
43
import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';
44
import { IPreferencesService } from '../../../../services/preferences/common/preferences.js';
45
import { disposableTimeout } from '../../../../../base/common/async.js';
46
import { MarkdownString } from '../../../../../base/common/htmlContent.js';
47
import { IsSessionsWindowContext } from '../../../../common/contextkeys.js';
48
49
const CONTEXT_BROWSER_EDITOR_OPEN = new RawContextKey<boolean>('browserEditorOpen', false, localize('browser.editorOpen', "Whether any browser editor is currently open"));
50
51
interface IBrowserQuickPickItem extends IQuickPickItem {
52
groupId: GroupIdentifier;
53
editor: BrowserEditorInput;
54
}
55
56
const closeButtonItem: IQuickInputButton = {
57
iconClass: ThemeIcon.asClassName(Codicon.close),
58
tooltip: localize('browser.closeTab', "Close")
59
};
60
61
const closeAllButtonItem: IQuickInputButton = {
62
iconClass: ThemeIcon.asClassName(Codicon.closeAll),
63
tooltip: localize('browser.closeAllTabs', "Close All"),
64
location: QuickInputButtonLocation.Inline
65
};
66
67
68
/**
69
* Manages a quick pick that lists all open browser tabs grouped by editor group,
70
* with close buttons, live updates, and an always-visible "New Integrated Browser Tab" entry.
71
*/
72
class BrowserTabQuickPick extends Disposable {
73
74
private readonly _quickPick: IQuickPick<IBrowserQuickPickItem, { useSeparators: true }>;
75
private readonly _itemListeners = this._register(new DisposableStore());
76
77
private readonly _openNewTabPick: IBrowserQuickPickItem = {
78
groupId: -1,
79
editor: undefined!,
80
label: localize('browser.openNewTab', "New Integrated Browser Tab"),
81
iconClass: ThemeIcon.asClassName(Codicon.add),
82
alwaysShow: true,
83
};
84
85
constructor(
86
@IEditorService private readonly _editorService: IEditorService,
87
@IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService,
88
@IQuickInputService quickInputService: IQuickInputService,
89
@ITelemetryService telemetryService: ITelemetryService,
90
@IBrowserViewWorkbenchService private readonly _browserViewService: IBrowserViewWorkbenchService,
91
) {
92
super();
93
94
this._quickPick = this._register(quickInputService.createQuickPick<IBrowserQuickPickItem>({ useSeparators: true }));
95
this._quickPick.placeholder = localize('browser.quickOpenPlaceholder', "Select a browser tab");
96
this._quickPick.matchOnDescription = true;
97
this._quickPick.sortByLabel = false;
98
this._quickPick.buttons = [closeAllButtonItem];
99
100
this._register(this._quickPick.onDidTriggerItemButton(async ({ item }) => {
101
item.editor?.dispose(true);
102
}));
103
104
this._register(this._quickPick.onDidTriggerButton(async () => {
105
for (const editor of this._browserViewService.getKnownBrowserViews().values()) {
106
editor.dispose(true);
107
}
108
}));
109
110
this._register(this._quickPick.onDidAccept(async () => {
111
const [selected] = this._quickPick.selectedItems;
112
if (!selected) {
113
return;
114
}
115
if (selected === this._openNewTabPick) {
116
logBrowserOpen(telemetryService, 'quickOpenWithoutUrl');
117
this._quickPick.hide();
118
await this._editorService.openEditor({
119
resource: BrowserViewUri.forId(generateUuid()),
120
});
121
} else {
122
await this._editorService.openEditor(selected.editor, selected.groupId);
123
}
124
}));
125
126
this._register(this._quickPick.onDidHide(() => this.dispose()));
127
}
128
129
show(): void {
130
this._buildItems();
131
132
// Pre-select the currently active browser editor
133
const activeEditor = this._editorService.activeEditor;
134
if (activeEditor instanceof BrowserEditorInput) {
135
const activePick = (this._quickPick.items as readonly (IBrowserQuickPickItem | IQuickPickSeparator)[])
136
.find((item): item is IBrowserQuickPickItem => item.type !== 'separator' && item.editor === activeEditor);
137
if (activePick) {
138
this._quickPick.activeItems = [activePick];
139
}
140
}
141
142
this._quickPick.show();
143
}
144
145
private _buildItems(): void {
146
this._itemListeners.clear();
147
148
// Remember which editor was active so we can restore selection
149
const activeEditor = this._quickPick.activeItems[0]?.editor;
150
151
const picks: (IBrowserQuickPickItem | IQuickPickSeparator)[] = [];
152
const groups = this._editorGroupsService.getGroups(GroupsOrder.GRID_APPEARANCE);
153
154
const groupsWithBrowserEditors = groups
155
.map(group => ({ group, browserEditors: group.editors.filter((e): e is BrowserEditorInput => e instanceof BrowserEditorInput) }))
156
.filter(({ browserEditors }) => browserEditors.length > 0);
157
158
// Track which view IDs appear in at least one editor group
159
const viewsInGroups = new Set<string>();
160
for (const { browserEditors } of groupsWithBrowserEditors) {
161
for (const editor of browserEditors) {
162
viewsInGroups.add(editor.id);
163
}
164
}
165
166
// Background views: known but not open in any editor group
167
const backgroundEditors = [...this._browserViewService.getKnownBrowserViews().values()].filter(e => !viewsInGroups.has(e.id));
168
const backgroundLabel = localize('browser.backgroundGroup', "Background");
169
170
// Build sections: each editor group + optional background
171
type Section = { label: string; ariaLabel: string; groupId: number; editors: BrowserEditorInput[]; isPinned?: (e: BrowserEditorInput) => boolean };
172
const sections: Section[] = groupsWithBrowserEditors.map(({ group, browserEditors }) => ({
173
label: group.label,
174
ariaLabel: group.ariaLabel,
175
groupId: group.id,
176
editors: browserEditors,
177
isPinned: e => group.isPinned(e),
178
}));
179
if (backgroundEditors.length > 0) {
180
sections.push({ label: backgroundLabel, ariaLabel: backgroundLabel, groupId: ACTIVE_GROUP, editors: backgroundEditors });
181
}
182
for (const { group } of groupsWithBrowserEditors) {
183
this._itemListeners.add(group.onDidModelChange(() => this._buildItems()));
184
}
185
this._itemListeners.add(this._browserViewService.onDidChangeBrowserViews(() => this._buildItems()));
186
187
const hasMultipleSections = sections.length > 1;
188
let newActivePick: IBrowserQuickPickItem | undefined;
189
190
for (const section of sections) {
191
if (hasMultipleSections) {
192
picks.push({ type: 'separator', label: section.label });
193
}
194
for (const editor of section.editors) {
195
const icon = editor.getIcon();
196
const description = editor.getDescription();
197
const nameAndDescription = description ? `${editor.getName()} ${description}` : editor.getName();
198
const pick: IBrowserQuickPickItem = {
199
groupId: section.groupId,
200
editor,
201
label: editor.getName(),
202
ariaLabel: hasMultipleSections
203
? localize('browserEntryAriaLabelWithGroup', "{0}, {1}", nameAndDescription, section.ariaLabel)
204
: nameAndDescription,
205
description,
206
buttons: [closeButtonItem],
207
italic: section.isPinned ? !section.isPinned(editor) : undefined,
208
};
209
if (icon instanceof URI) {
210
pick.iconPath = { dark: icon };
211
} else if (icon) {
212
pick.iconClass = ThemeIcon.asClassName(icon);
213
}
214
picks.push(pick);
215
216
if (editor === activeEditor) {
217
newActivePick = pick;
218
}
219
220
this._itemListeners.add(editor.onDidChangeLabel(() => this._buildItems()));
221
}
222
}
223
224
picks.push({ type: 'separator' });
225
picks.push(this._openNewTabPick);
226
227
this._quickPick.keepScrollPosition = true;
228
this._quickPick.items = picks;
229
if (newActivePick) {
230
this._quickPick.activeItems = [newActivePick];
231
}
232
}
233
}
234
235
class QuickOpenBrowserAction extends Action2 {
236
constructor() {
237
super({
238
id: BrowserViewCommandId.QuickOpen,
239
title: localize2('browser.quickOpenAction', "Quick Open Browser Tab..."),
240
icon: Codicon.globe,
241
category: BrowserActionCategory,
242
f1: true,
243
keybinding: {
244
weight: KeybindingWeight.WorkbenchContrib,
245
// Note: on Linux this conflicts with the "toggle block comment" keybinding.
246
// it's not as problem at the moment becase oh the `when`, but worth noting for the future.
247
primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyA,
248
when: BROWSER_EDITOR_ACTIVE
249
},
250
});
251
}
252
253
run(accessor: ServicesAccessor): void {
254
const picker = accessor.get(IInstantiationService).createInstance(BrowserTabQuickPick);
255
picker.show();
256
}
257
}
258
259
interface IOpenBrowserOptions {
260
url?: string;
261
openToSide?: boolean;
262
263
/**
264
* If set, the first existing tab with a URL matching this glob pattern will be reused / focused instead of opening a new tab.
265
*
266
* This is used by Live Preview extension to reuse tabs, especially after reload / restart.
267
*/
268
reuseUrlFilter?: string;
269
}
270
271
class OpenIntegratedBrowserAction extends Action2 {
272
constructor() {
273
super({
274
id: BrowserViewCommandId.Open,
275
title: localize2('browser.openAction', "Open Integrated Browser"),
276
category: BrowserActionCategory,
277
icon: Codicon.globe,
278
f1: true,
279
});
280
}
281
282
async run(accessor: ServicesAccessor, urlOrOptions?: string | IOpenBrowserOptions): Promise<void> {
283
const editorService = accessor.get(IEditorService);
284
const telemetryService = accessor.get(ITelemetryService);
285
const browserViewService = accessor.get(IBrowserViewWorkbenchService);
286
287
// Parse arguments
288
const options = typeof urlOrOptions === 'string' ? { url: urlOrOptions } : (urlOrOptions ?? {});
289
const resource = BrowserViewUri.forId(generateUuid());
290
const group = options.openToSide ? SIDE_GROUP : ACTIVE_GROUP;
291
292
if (options.reuseUrlFilter) {
293
const filterUri = URI.parse(options.reuseUrlFilter);
294
const matchingEditor = [...browserViewService.getKnownBrowserViews().values()].find((e) => {
295
const editorUri = URI.parse(e.url || '');
296
// URIs default to putting "file" scheme. Check that the scheme is really in the filter.
297
if (filterUri.scheme && options.reuseUrlFilter!.startsWith(`${filterUri.scheme}:`) && filterUri.scheme !== editorUri.scheme) {
298
return false;
299
}
300
if (filterUri.authority && !match(filterUri.authority, editorUri.authority)) {
301
return false;
302
}
303
if (filterUri.path && !match(filterUri.path, editorUri.path)) {
304
return false;
305
}
306
if (filterUri.query) {
307
const filterParams = new URLSearchParams(filterUri.query);
308
const editorParams = new URLSearchParams(editorUri.query);
309
if (![...filterParams].every(([key, value]) => match(value, editorParams.get(key) ?? ''))) {
310
return false;
311
}
312
}
313
314
return true;
315
});
316
if (matchingEditor) {
317
if (options.url) {
318
matchingEditor.navigate(options.url);
319
}
320
await editorService.openEditor(matchingEditor);
321
return;
322
}
323
}
324
325
logBrowserOpen(telemetryService, options.url ? 'commandWithUrl' : 'commandWithoutUrl');
326
327
const editorPane = await editorService.openEditor({ resource, options: { viewState: { url: options.url } } }, group);
328
329
// Lock the group when opening to the side
330
if (options.openToSide && editorPane?.group) {
331
editorPane.group.lock(true);
332
}
333
}
334
}
335
336
class NewTabAction extends Action2 {
337
constructor() {
338
super({
339
id: BrowserViewCommandId.NewTab,
340
title: localize2('browser.newTabAction', "New Tab"),
341
category: BrowserActionCategory,
342
f1: true,
343
precondition: BROWSER_EDITOR_ACTIVE,
344
menu: {
345
id: MenuId.BrowserActionsToolbar,
346
group: BrowserActionGroup.Tabs,
347
order: 1,
348
},
349
// When already in a browser, Ctrl/Cmd + T opens a new tab
350
keybinding: {
351
weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over search actions
352
primary: KeyMod.CtrlCmd | KeyCode.KeyT,
353
}
354
});
355
}
356
357
async run(accessor: ServicesAccessor, _browserEditor = accessor.get(IEditorService).activeEditorPane): Promise<void> {
358
const editorService = accessor.get(IEditorService);
359
const telemetryService = accessor.get(ITelemetryService);
360
const resource = BrowserViewUri.forId(generateUuid());
361
362
logBrowserOpen(telemetryService, 'newTabCommand');
363
364
await editorService.openEditor({ resource });
365
}
366
}
367
368
class CloseAllBrowserTabsAction extends Action2 {
369
constructor() {
370
super({
371
id: BrowserViewCommandId.CloseAll,
372
title: localize2('browser.closeAll', "Close All Browser Tabs"),
373
category: BrowserActionCategory,
374
f1: true,
375
precondition: CONTEXT_BROWSER_EDITOR_OPEN,
376
});
377
}
378
379
async run(accessor: ServicesAccessor): Promise<void> {
380
const editorGroupsService = accessor.get(IEditorGroupsService);
381
for (const group of editorGroupsService.getGroups(GroupsOrder.GRID_APPEARANCE)) {
382
const browserEditors = group.getEditors(EditorsOrder.SEQUENTIAL).filter((e): e is BrowserEditorInput => e instanceof BrowserEditorInput);
383
if (browserEditors.length > 0) {
384
await group.closeEditors(browserEditors);
385
}
386
}
387
}
388
}
389
390
class CloseAllBrowserTabsInGroupAction extends Action2 {
391
constructor() {
392
super({
393
id: BrowserViewCommandId.CloseAllInGroup,
394
title: localize2('browser.closeAllInGroup', "Close All Browser Tabs in Group"),
395
category: BrowserActionCategory,
396
f1: true,
397
precondition: BROWSER_EDITOR_ACTIVE,
398
});
399
}
400
401
async run(accessor: ServicesAccessor): Promise<void> {
402
const editorGroupsService = accessor.get(IEditorGroupsService);
403
const editorService = accessor.get(IEditorService);
404
const group = editorGroupsService.getGroup(editorService.activeEditorPane?.group?.id ?? editorGroupsService.activeGroup.id);
405
if (!group) {
406
return;
407
}
408
const browserEditors = group.getEditors(EditorsOrder.SEQUENTIAL).filter((e): e is BrowserEditorInput => e instanceof BrowserEditorInput);
409
if (browserEditors.length > 0) {
410
await group.closeEditors(browserEditors);
411
}
412
}
413
}
414
415
class OpenOrListBrowsersAction extends Action2 {
416
constructor() {
417
super({
418
id: BrowserViewCommandId.OpenOrList,
419
title: localize2('browser.openOrListAction', "Browser"),
420
icon: Codicon.globe,
421
f1: false,
422
keybinding: {
423
weight: KeybindingWeight.WorkbenchContrib,
424
primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.Slash,
425
},
426
menu: {
427
id: MenuId.TitleBar,
428
group: 'navigation',
429
order: 10,
430
when: ContextKeyExpr.and(
431
ContextKeyExpr.equals('config.workbench.browser.showInTitleBar', false).negate(),
432
ContextKeyExpr.or(
433
CONTEXT_BROWSER_EDITOR_OPEN,
434
// This is a hack to work around `true` just testing for truthiness of the key. It works since `1 == true` in JS.
435
ContextKeyExpr.equals('config.workbench.browser.showInTitleBar', 1)
436
)
437
),
438
}
439
});
440
}
441
442
async run(accessor: ServicesAccessor): Promise<void> {
443
const browserViewService = accessor.get(IBrowserViewWorkbenchService);
444
const commandService = accessor.get(ICommandService);
445
446
const hasOpenBrowserEditor = browserViewService.getKnownBrowserViews().size > 0;
447
448
if (hasOpenBrowserEditor) {
449
await commandService.executeCommand(BrowserViewCommandId.QuickOpen);
450
return;
451
}
452
453
await commandService.executeCommand(BrowserViewCommandId.Open);
454
}
455
}
456
457
// Register in View menu
458
MenuRegistry.appendMenuItem(MenuId.MenubarViewMenu, {
459
group: '4_auxbar',
460
command: {
461
id: BrowserViewCommandId.OpenOrList,
462
title: localize({ key: 'miOpenBrowser', comment: ['&& denotes a mnemonic'] }, "&&Browser")
463
},
464
order: 2
465
});
466
467
// Register as "Close All Browser Tabs" action in editor title menu to align with the regular "Close All" action
468
MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: BrowserViewCommandId.CloseAllInGroup, title: localize('browser.closeAllInGroupShort', "Close All Browser Tabs") }, group: '1_close', order: 55, when: BROWSER_EDITOR_ACTIVE });
469
470
registerAction2(QuickOpenBrowserAction);
471
registerAction2(OpenIntegratedBrowserAction);
472
registerAction2(OpenOrListBrowsersAction);
473
registerAction2(NewTabAction);
474
registerAction2(CloseAllBrowserTabsAction);
475
registerAction2(CloseAllBrowserTabsInGroupAction);
476
477
registerAction2(class ToggleBrowserTitleBarButton extends ToggleTitleBarConfigAction {
478
constructor() {
479
super('workbench.browser.showInTitleBar', localize('toggle.browser', 'Integrated Browser'), localize('toggle.browserDescription', "Toggle visibility of the Integrated Browser button in title bar"), 8);
480
}
481
});
482
483
/**
484
* Tracks whether any browser editor is open across all editor groups and
485
* keeps the `browserEditorOpen` context key in sync.
486
*/
487
class BrowserEditorOpenContextKeyContribution extends Disposable implements IWorkbenchContribution {
488
static readonly ID = 'workbench.contrib.browserEditorOpenContextKey';
489
490
constructor(
491
@IContextKeyService contextKeyService: IContextKeyService,
492
@IBrowserViewWorkbenchService browserViewService: IBrowserViewWorkbenchService,
493
) {
494
super();
495
496
const contextKey = CONTEXT_BROWSER_EDITOR_OPEN.bindTo(contextKeyService);
497
const update = () => contextKey.set(browserViewService.getKnownBrowserViews().size > 0);
498
499
update();
500
this._register(browserViewService.onDidChangeBrowserViews(() => update()));
501
}
502
}
503
504
registerWorkbenchContribution2(BrowserEditorOpenContextKeyContribution.ID, BrowserEditorOpenContextKeyContribution, WorkbenchPhase.AfterRestored);
505
506
/**
507
* Opens localhost URLs in the Integrated Browser when the setting is enabled.
508
*/
509
class LocalhostLinkOpenerContribution extends Disposable implements IWorkbenchContribution, IExternalOpener {
510
static readonly ID = 'workbench.contrib.localhostLinkOpener';
511
512
constructor(
513
@IOpenerService openerService: IOpenerService,
514
@IConfigurationService private readonly configurationService: IConfigurationService,
515
@IEditorService private readonly editorService: IEditorService,
516
@ITelemetryService private readonly telemetryService: ITelemetryService
517
) {
518
super();
519
520
this._register(openerService.registerExternalOpener(this));
521
}
522
523
async openExternal(href: string, _ctx: { sourceUri: URI; preferredOpenerId?: string }, _token: CancellationToken): Promise<boolean> {
524
if (!this.configurationService.getValue<boolean>('workbench.browser.openLocalhostLinks')) {
525
return false;
526
}
527
528
try {
529
const parsed = new URL(href);
530
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
531
return false;
532
}
533
if (!isLocalhostAuthority(parsed.host)) {
534
return false;
535
}
536
} catch {
537
return false;
538
}
539
540
logBrowserOpen(this.telemetryService, 'localhostLinkOpener');
541
542
// Check whether the setting was explicitly set by the user or is still at its default value.
543
// When it is a default, tag the viewState so that the hint pill can be shown.
544
const isDefaultLinkOpen = !isConfigured(this.configurationService.inspect('workbench.browser.openLocalhostLinks'));
545
546
const browserUri = BrowserViewUri.forId(generateUuid());
547
await this.editorService.openEditor({ resource: browserUri, options: { pinned: true, viewState: { url: href, isDefaultLinkOpen } } });
548
return true;
549
}
550
}
551
552
registerWorkbenchContribution2(LocalhostLinkOpenerContribution.ID, LocalhostLinkOpenerContribution, WorkbenchPhase.BlockStartup);
553
554
// ---- Link opened hint pill (URL bar widget) --------------------------------
555
556
const LOCALHOST_HINT_DISMISSED_KEY = 'workbench.browser.linkOpenedHintDismissed';
557
558
/**
559
* A small pill shown in the URL bar that informs the user their link was opened
560
* in the Integrated Browser by default. Clicking it shows a tooltip
561
* with an explanation and options to open settings or dismiss permanently.
562
*/
563
class LinkOpenedHintPill extends BrowserEditorContribution {
564
565
private readonly _pill: HTMLElement;
566
private readonly _attentionTimeout = this._register(new MutableDisposable());
567
568
constructor(
569
editor: BrowserEditor,
570
@IHoverService private readonly hoverService: IHoverService,
571
@IStorageService private readonly storageService: IStorageService,
572
@IPreferencesService private readonly preferencesService: IPreferencesService,
573
@IContextKeyService private readonly contextKeyService: IContextKeyService
574
) {
575
super(editor);
576
577
this._pill = $('.browser-link-opened-hint-pill');
578
this._pill.tabIndex = 0;
579
this._pill.role = 'button';
580
this._pill.ariaLabel = localize('browser.linkOpenedHint.ariaLabel', "This link opened in the integrated browser");
581
this._pill.ariaHidden = 'true';
582
583
const icon = $('span');
584
icon.className = ThemeIcon.asClassName(Codicon.info);
585
const label = $('span');
586
label.textContent = localize('browser.linkOpenedHint.label', "Link opened here");
587
588
this._pill.appendChild(icon);
589
this._pill.appendChild(label);
590
591
const hoverOptions = () => ({
592
content: new MarkdownString(localize('browser.linkOpenedHint.detail', "**Integrated Browser**\n\nLocalhost links automatically open in the integrated browser.")),
593
actions: [
594
{
595
label: localize('browser.linkOpenedHint.openSettings', "Open Settings"),
596
commandId: 'workbench.action.openSettings',
597
iconClass: ThemeIcon.asClassName(Codicon.settingsGear),
598
run: () => {
599
this.preferencesService.openUserSettings({ query: 'workbench.browser.openLocalhostLinks' });
600
}
601
},
602
{
603
label: localize('browser.linkOpenedHint.dismiss', "Don't Show Again"),
604
commandId: '',
605
run: () => {
606
this._dismiss();
607
}
608
}
609
],
610
position: { hoverPosition: HoverPosition.BELOW }
611
});
612
613
this._register(this.hoverService.setupDelayedHover(this._pill, hoverOptions, { setupKeyboardEvents: true }));
614
this._register(addDisposableListener(this._pill, EventType.CLICK, () => {
615
this.hoverService.showInstantHover({ ...hoverOptions(), target: this._pill, persistence: { sticky: true } }, true);
616
}));
617
}
618
619
override get urlBarWidgets(): readonly IBrowserEditorWidgetContribution[] {
620
return [{ element: this._pill, order: 100 }];
621
}
622
623
protected override subscribeToModel(_model: IBrowserViewModel, _store: DisposableStore, isNew: boolean): void {
624
if (IsSessionsWindowContext.getValue(this.contextKeyService)) {
625
this._setVisible(false);
626
return;
627
}
628
629
const input = this.editor.input;
630
if (input instanceof BrowserEditorInput && input.isDefaultLinkOpen) {
631
const dismissed = this.storageService.getBoolean(LOCALHOST_HINT_DISMISSED_KEY, StorageScope.APPLICATION, false);
632
this._setVisible(!dismissed);
633
if (!dismissed && isNew) {
634
this._callAttention();
635
}
636
} else {
637
this._setVisible(false);
638
}
639
}
640
641
override clear(): void {
642
this._attentionTimeout.clear();
643
this._setVisible(false);
644
}
645
646
private _setVisible(visible: boolean): void {
647
if (!visible) {
648
this._attentionTimeout.clear();
649
this._pill.classList.remove('attention');
650
}
651
this._pill.classList.toggle('visible', visible);
652
this._pill.ariaHidden = visible ? 'false' : 'true';
653
}
654
655
private _callAttention(): void {
656
this._attentionTimeout.clear();
657
this._pill.classList.remove('attention');
658
// Start collapsed (icon only), expand after 300ms, then collapse back after another 2s
659
this._attentionTimeout.value = disposableTimeout(() => {
660
this._pill.classList.add('attention');
661
this._attentionTimeout.value = disposableTimeout(() => {
662
this._pill.classList.remove('attention');
663
}, 2000);
664
}, 300);
665
}
666
667
private _dismiss(): void {
668
this.storageService.store(LOCALHOST_HINT_DISMISSED_KEY, true, StorageScope.APPLICATION, StorageTarget.USER);
669
this._setVisible(false);
670
}
671
}
672
673
BrowserEditor.registerContribution(LinkOpenedHintPill);
674
675
Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).registerConfiguration({
676
...workbenchConfigurationNodeBase,
677
properties: {
678
'workbench.browser.showInTitleBar': {
679
type: ['boolean', 'string'],
680
enum: [true, false, 'whenOpen'],
681
enumDescriptions: [
682
localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.showInTitleBar.true' }, 'The button is always shown in the title bar.'),
683
localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.showInTitleBar.false' }, 'The button is never shown in the title bar.'),
684
localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.showInTitleBar.whenOpen' }, 'The button is shown in the title bar when a browser editor is open.')
685
],
686
default: 'whenOpen',
687
experiment: { mode: 'startup' },
688
description: localize(
689
{ comment: ['This is the description for a setting.'], key: 'browser.showInTitleBar' },
690
'Controls whether the Integrated Browser button is shown in the title bar.'
691
)
692
},
693
'workbench.browser.openLocalhostLinks': {
694
type: 'boolean',
695
default: false,
696
experiment: { mode: 'startup' },
697
markdownDescription: localize(
698
{ comment: ['This is the description for a setting.'], key: 'browser.openLocalhostLinks' },
699
'When enabled, localhost links from the terminal, chat, and other sources will open in the Integrated Browser instead of the system browser.'
700
)
701
}
702
}
703
});
704
705