Path: blob/main/src/vs/workbench/browser/actions/windowActions.ts
5237 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { localize, localize2 } from '../../../nls.js';6import { IWindowOpenable } from '../../../platform/window/common/window.js';7import { IDialogService } from '../../../platform/dialogs/common/dialogs.js';8import { MenuRegistry, MenuId, Action2, registerAction2 } from '../../../platform/actions/common/actions.js';9import { KeyChord, KeyCode, KeyMod } from '../../../base/common/keyCodes.js';10import { IsMainWindowFullscreenContext } from '../../common/contextkeys.js';11import { IsMacNativeContext, IsDevelopmentContext, IsWebContext, IsIOSContext } from '../../../platform/contextkey/common/contextkeys.js';12import { Categories } from '../../../platform/action/common/actionCommonCategories.js';13import { KeybindingsRegistry, KeybindingWeight } from '../../../platform/keybinding/common/keybindingsRegistry.js';14import { IQuickInputButton, IQuickInputService, IQuickPickSeparator, IKeyMods, IQuickPickItem } from '../../../platform/quickinput/common/quickInput.js';15import { IWorkspaceContextService, IWorkspaceIdentifier, isWorkspaceIdentifier, isSingleFolderWorkspaceIdentifier } from '../../../platform/workspace/common/workspace.js';16import { ILabelService, Verbosity } from '../../../platform/label/common/label.js';17import { IKeybindingService } from '../../../platform/keybinding/common/keybinding.js';18import { IModelService } from '../../../editor/common/services/model.js';19import { ILanguageService } from '../../../editor/common/languages/language.js';20import { IRecent, isRecentFolder, isRecentWorkspace, IWorkspacesService } from '../../../platform/workspaces/common/workspaces.js';21import { URI } from '../../../base/common/uri.js';22import { getIconClasses } from '../../../editor/common/services/getIconClasses.js';23import { FileKind } from '../../../platform/files/common/files.js';24import { splitRecentLabel } from '../../../base/common/labels.js';25import { isMacintosh, isWeb, isWindows } from '../../../base/common/platform.js';26import { ContextKeyExpr } from '../../../platform/contextkey/common/contextkey.js';27import { inQuickPickContext, getQuickNavigateHandler } from '../quickaccess.js';28import { IHostService } from '../../services/host/browser/host.js';29import { ResourceMap } from '../../../base/common/map.js';30import { Codicon } from '../../../base/common/codicons.js';31import { ThemeIcon } from '../../../base/common/themables.js';32import { CommandsRegistry } from '../../../platform/commands/common/commands.js';33import { IConfigurationService } from '../../../platform/configuration/common/configuration.js';34import { ServicesAccessor } from '../../../platform/instantiation/common/instantiation.js';35import { isFolderBackupInfo, isWorkspaceBackupInfo } from '../../../platform/backup/common/backup.js';36import { getActiveElement, getActiveWindow, isHTMLElement } from '../../../base/browser/dom.js';3738export const inRecentFilesPickerContextKey = 'inRecentFilesPicker';3940interface IRecentlyOpenedPick extends IQuickPickItem {41resource: URI;42openable: IWindowOpenable;43remoteAuthority: string | undefined;44}4546abstract class BaseOpenRecentAction extends Action2 {4748private readonly removeFromRecentlyOpened: IQuickInputButton = {49iconClass: ThemeIcon.asClassName(Codicon.removeClose),50tooltip: localize('remove', "Remove from Recently Opened")51};5253private readonly dirtyRecentlyOpenedFolder: IQuickInputButton = {54iconClass: 'dirty-workspace ' + ThemeIcon.asClassName(Codicon.closeDirty),55tooltip: localize('dirtyRecentlyOpenedFolder', "Folder With Unsaved Files"),56alwaysVisible: true57};5859private readonly dirtyRecentlyOpenedWorkspace: IQuickInputButton = {60...this.dirtyRecentlyOpenedFolder,61tooltip: localize('dirtyRecentlyOpenedWorkspace', "Workspace With Unsaved Files"),62};6364private readonly windowOpenedRecentlyOpenedFolder: IQuickInputButton = {65iconClass: 'opened-workspace ' + ThemeIcon.asClassName(Codicon.window),66tooltip: localize('openedRecentlyOpenedFolder', "Folder Opened in a Window"),67alwaysVisible: true68};6970private readonly windowOpenedRecentlyOpenedWorkspace: IQuickInputButton = {71...this.windowOpenedRecentlyOpenedFolder,72tooltip: localize('openedRecentlyOpenedWorkspace', "Workspace Opened in a Window"),73};7475private readonly activeWindowOpenedRecentlyOpenedFolder: IQuickInputButton = {76iconClass: 'opened-workspace ' + ThemeIcon.asClassName(Codicon.windowActive),77tooltip: localize('activeOpenedRecentlyOpenedFolder', "Folder Opened in Active Window"),78alwaysVisible: true79};8081private readonly activeWindowOpenedRecentlyOpenedWorkspace: IQuickInputButton = {82...this.activeWindowOpenedRecentlyOpenedFolder,83tooltip: localize('activeOpenedRecentlyOpenedWorkspace', "Workspace Opened in Active Window"),84};8586protected abstract isQuickNavigate(): boolean;8788override async run(accessor: ServicesAccessor): Promise<void> {89const workspacesService = accessor.get(IWorkspacesService);90const quickInputService = accessor.get(IQuickInputService);91const contextService = accessor.get(IWorkspaceContextService);92const labelService = accessor.get(ILabelService);93const keybindingService = accessor.get(IKeybindingService);94const modelService = accessor.get(IModelService);95const languageService = accessor.get(ILanguageService);96const hostService = accessor.get(IHostService);97const dialogService = accessor.get(IDialogService);9899const [mainWindows, recentlyOpened, dirtyWorkspacesAndFolders] = await Promise.all([100hostService.getWindows({ includeAuxiliaryWindows: false }),101workspacesService.getRecentlyOpened(),102workspacesService.getDirtyWorkspaces()103]);104105let hasWorkspaces = false;106107// Identify all folders and workspaces with unsaved files108const dirtyFolders = new ResourceMap<boolean>();109const dirtyWorkspaces = new ResourceMap<IWorkspaceIdentifier>();110for (const dirtyWorkspace of dirtyWorkspacesAndFolders) {111if (isFolderBackupInfo(dirtyWorkspace)) {112dirtyFolders.set(dirtyWorkspace.folderUri, true);113} else {114dirtyWorkspaces.set(dirtyWorkspace.workspace.configPath, dirtyWorkspace.workspace);115hasWorkspaces = true;116}117}118119// Identify all folders and workspaces opened in main windows120const activeWindowId = getActiveWindow().vscodeWindowId;121const openedInWindows = new ResourceMap<{ isActive: boolean }>();122for (const window of mainWindows) {123const isActive = window.id === activeWindowId;124if (isSingleFolderWorkspaceIdentifier(window.workspace)) {125openedInWindows.set(window.workspace.uri, { isActive });126} else if (isWorkspaceIdentifier(window.workspace)) {127openedInWindows.set(window.workspace.configPath, { isActive });128}129}130131// Identify all recently opened folders and workspaces132const recentFolders = new ResourceMap<boolean>();133const recentWorkspaces = new ResourceMap<IWorkspaceIdentifier>();134for (const recent of recentlyOpened.workspaces) {135if (isRecentFolder(recent)) {136recentFolders.set(recent.folderUri, true);137} else {138recentWorkspaces.set(recent.workspace.configPath, recent.workspace);139hasWorkspaces = true;140}141}142143// Fill in all known recently opened workspaces144const workspacePicks: IRecentlyOpenedPick[] = [];145for (const recent of recentlyOpened.workspaces) {146const isDirty = isRecentFolder(recent) ? dirtyFolders.has(recent.folderUri) : dirtyWorkspaces.has(recent.workspace.configPath);147const windowState = isRecentFolder(recent) ? openedInWindows.get(recent.folderUri) : openedInWindows.get(recent.workspace.configPath);148149workspacePicks.push(this.toQuickPick(modelService, languageService, labelService, recent, { isDirty, windowState }));150}151152// Fill any backup workspace that is not yet shown at the end153for (const dirtyWorkspaceOrFolder of dirtyWorkspacesAndFolders) {154if (isFolderBackupInfo(dirtyWorkspaceOrFolder) && !recentFolders.has(dirtyWorkspaceOrFolder.folderUri)) {155workspacePicks.push(this.toQuickPick(modelService, languageService, labelService, dirtyWorkspaceOrFolder, { isDirty: true, windowState: undefined }));156} else if (isWorkspaceBackupInfo(dirtyWorkspaceOrFolder) && !recentWorkspaces.has(dirtyWorkspaceOrFolder.workspace.configPath)) {157workspacePicks.push(this.toQuickPick(modelService, languageService, labelService, dirtyWorkspaceOrFolder, { isDirty: true, windowState: undefined }));158}159}160161const filePicks = recentlyOpened.files.map(p => this.toQuickPick(modelService, languageService, labelService, p, { isDirty: false, windowState: undefined }));162163// focus second entry if the first recent workspace is the current workspace164const firstEntry = recentlyOpened.workspaces[0];165const autoFocusSecondEntry: boolean = firstEntry && contextService.isCurrentWorkspace(isRecentWorkspace(firstEntry) ? firstEntry.workspace : firstEntry.folderUri);166167let keyMods: IKeyMods | undefined;168169const workspaceSeparator: IQuickPickSeparator = { type: 'separator', label: hasWorkspaces ? localize('workspacesAndFolders', "folders & workspaces") : localize('folders', "folders") };170const fileSeparator: IQuickPickSeparator = { type: 'separator', label: localize('files', "files") };171const picks = [workspaceSeparator, ...workspacePicks, fileSeparator, ...filePicks];172173const pick = await quickInputService.pick(picks, {174contextKey: inRecentFilesPickerContextKey,175activeItem: [...workspacePicks, ...filePicks][autoFocusSecondEntry ? 1 : 0],176placeHolder: isMacintosh ? localize('openRecentPlaceholderMac', "Select to open (hold Cmd-key to force new window or Option-key for same window)") : localize('openRecentPlaceholder', "Select to open (hold Ctrl-key to force new window or Alt-key for same window)"),177matchOnDescription: true,178sortByLabel: false,179onKeyMods: mods => keyMods = mods,180quickNavigate: this.isQuickNavigate() ? { keybindings: keybindingService.lookupKeybindings(this.desc.id) } : undefined,181hideInput: this.isQuickNavigate(),182onDidTriggerItemButton: async context => {183184// Remove185if (context.button === this.removeFromRecentlyOpened || context.button === this.windowOpenedRecentlyOpenedFolder || context.button === this.windowOpenedRecentlyOpenedWorkspace) {186await workspacesService.removeRecentlyOpened([context.item.resource]);187context.removeItem();188}189190// Dirty Folder/Workspace191else if (context.button === this.dirtyRecentlyOpenedFolder || context.button === this.dirtyRecentlyOpenedWorkspace) {192const isDirtyWorkspace = context.button === this.dirtyRecentlyOpenedWorkspace;193const { confirmed } = await dialogService.confirm({194title: isDirtyWorkspace ? localize('dirtyWorkspace', "Workspace with Unsaved Files") : localize('dirtyFolder', "Folder with Unsaved Files"),195message: isDirtyWorkspace ? localize('dirtyWorkspaceConfirm', "Do you want to open the workspace to review the unsaved files?") : localize('dirtyFolderConfirm', "Do you want to open the folder to review the unsaved files?"),196detail: isDirtyWorkspace ? localize('dirtyWorkspaceConfirmDetail', "Workspaces with unsaved files cannot be removed until all unsaved files have been saved or reverted.") : localize('dirtyFolderConfirmDetail', "Folders with unsaved files cannot be removed until all unsaved files have been saved or reverted.")197});198199if (confirmed) {200hostService.openWindow(201[context.item.openable], {202remoteAuthority: context.item.remoteAuthority || null // local window if remoteAuthority is not set or can not be deducted from the openable203});204quickInputService.cancel();205}206}207}208});209210if (pick) {211return hostService.openWindow([pick.openable], {212forceNewWindow: keyMods?.ctrlCmd,213forceReuseWindow: keyMods?.alt,214remoteAuthority: pick.remoteAuthority || null // local window if remoteAuthority is not set or can not be deducted from the openable215});216}217}218219private toQuickPick(modelService: IModelService, languageService: ILanguageService, labelService: ILabelService, recent: IRecent, kind: { isDirty: boolean; windowState?: { isActive: boolean } }): IRecentlyOpenedPick {220let openable: IWindowOpenable | undefined;221let iconClasses: string[];222let fullLabel: string | undefined;223let resource: URI | undefined;224let isWorkspace = false;225226// Folder227if (isRecentFolder(recent)) {228resource = recent.folderUri;229iconClasses = getIconClasses(modelService, languageService, resource, FileKind.FOLDER);230openable = { folderUri: resource };231fullLabel = recent.label || labelService.getWorkspaceLabel(resource, { verbose: Verbosity.LONG });232}233234// Workspace235else if (isRecentWorkspace(recent)) {236resource = recent.workspace.configPath;237iconClasses = getIconClasses(modelService, languageService, resource, FileKind.ROOT_FOLDER);238openable = { workspaceUri: resource };239fullLabel = recent.label || labelService.getWorkspaceLabel(recent.workspace, { verbose: Verbosity.LONG });240isWorkspace = true;241}242243// File244else {245resource = recent.fileUri;246iconClasses = getIconClasses(modelService, languageService, resource, FileKind.FILE);247openable = { fileUri: resource };248fullLabel = recent.label || labelService.getUriLabel(resource, { appendWorkspaceSuffix: true });249}250251const { name, parentPath } = splitRecentLabel(fullLabel);252253const buttons: IQuickInputButton[] = [];254if (kind.isDirty) {255buttons.push(isWorkspace ? this.dirtyRecentlyOpenedWorkspace : this.dirtyRecentlyOpenedFolder);256} else if (kind.windowState) {257if (kind.windowState.isActive) {258buttons.push(isWorkspace ? this.activeWindowOpenedRecentlyOpenedWorkspace : this.activeWindowOpenedRecentlyOpenedFolder);259} else {260buttons.push(isWorkspace ? this.windowOpenedRecentlyOpenedWorkspace : this.windowOpenedRecentlyOpenedFolder);261}262} else {263buttons.push(this.removeFromRecentlyOpened);264}265266return {267iconClasses,268label: name,269ariaLabel: kind.isDirty ? isWorkspace ? localize('recentDirtyWorkspaceAriaLabel', "{0}, workspace with unsaved changes", name) : localize('recentDirtyFolderAriaLabel', "{0}, folder with unsaved changes", name) : name,270description: parentPath,271buttons,272openable,273resource,274remoteAuthority: recent.remoteAuthority275};276}277}278279export class OpenRecentAction extends BaseOpenRecentAction {280281static ID = 'workbench.action.openRecent';282283constructor() {284super({285id: OpenRecentAction.ID,286title: {287...localize2('openRecent', "Open Recent..."),288mnemonicTitle: localize({ key: 'miMore', comment: ['&& denotes a mnemonic'] }, "&&More..."),289},290category: Categories.File,291f1: true,292keybinding: {293weight: KeybindingWeight.WorkbenchContrib,294primary: KeyMod.CtrlCmd | KeyCode.KeyR,295mac: { primary: KeyMod.WinCtrl | KeyCode.KeyR }296},297menu: {298id: MenuId.MenubarRecentMenu,299group: 'y_more',300order: 1301}302});303}304305protected isQuickNavigate(): boolean {306return false;307}308}309310class QuickPickRecentAction extends BaseOpenRecentAction {311312constructor() {313super({314id: 'workbench.action.quickOpenRecent',315title: localize2('quickOpenRecent', 'Quick Open Recent...'),316category: Categories.File,317f1: false // hide quick pickers from command palette to not confuse with the other entry that shows a input field318});319}320321protected isQuickNavigate(): boolean {322return true;323}324}325326class ToggleFullScreenAction extends Action2 {327328constructor() {329super({330id: 'workbench.action.toggleFullScreen',331title: {332...localize2('toggleFullScreen', "Toggle Full Screen"),333mnemonicTitle: localize({ key: 'miToggleFullScreen', comment: ['&& denotes a mnemonic'] }, "&&Full Screen"),334},335category: Categories.View,336f1: true,337keybinding: {338weight: KeybindingWeight.WorkbenchContrib,339primary: KeyCode.F11,340mac: {341primary: KeyMod.CtrlCmd | KeyMod.WinCtrl | KeyCode.KeyF342}343},344precondition: IsIOSContext.toNegated(),345toggled: IsMainWindowFullscreenContext,346menu: [{347id: MenuId.MenubarAppearanceMenu,348group: '1_toggle_view',349order: 1350}]351});352}353354override run(accessor: ServicesAccessor): Promise<void> {355const hostService = accessor.get(IHostService);356357return hostService.toggleFullScreen(getActiveWindow());358}359}360361export class ReloadWindowAction extends Action2 {362363static readonly ID = 'workbench.action.reloadWindow';364365constructor() {366super({367id: ReloadWindowAction.ID,368title: localize2('reloadWindow', 'Reload Window'),369category: Categories.Developer,370f1: true,371keybinding: {372weight: KeybindingWeight.WorkbenchContrib + 50,373when: IsDevelopmentContext,374primary: KeyMod.CtrlCmd | KeyCode.KeyR375}376});377}378379override async run(accessor: ServicesAccessor): Promise<void> {380const hostService = accessor.get(IHostService);381382return hostService.reload();383}384}385386class ShowAboutDialogAction extends Action2 {387388constructor() {389super({390id: 'workbench.action.showAboutDialog',391title: {392...localize2('about', "About"),393mnemonicTitle: localize({ key: 'miAbout', comment: ['&& denotes a mnemonic'] }, "&&About"),394},395category: Categories.Help,396f1: true,397menu: {398id: MenuId.MenubarHelpMenu,399group: 'z_about',400order: 1,401when: IsMacNativeContext.toNegated()402}403});404}405406override run(accessor: ServicesAccessor): Promise<void> {407const dialogService = accessor.get(IDialogService);408409return dialogService.about();410}411}412413class NewWindowAction extends Action2 {414415constructor() {416super({417id: 'workbench.action.newWindow',418title: {419...localize2('newWindow', "New Window"),420mnemonicTitle: localize({ key: 'miNewWindow', comment: ['&& denotes a mnemonic'] }, "New &&Window"),421},422f1: true,423keybinding: {424weight: KeybindingWeight.WorkbenchContrib,425primary: isWeb ? (isWindows ? KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyMod.Shift | KeyCode.KeyN) : KeyMod.CtrlCmd | KeyMod.Alt | KeyMod.Shift | KeyCode.KeyN) : KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyN,426secondary: isWeb ? [KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyN] : undefined427},428menu: {429id: MenuId.MenubarFileMenu,430group: '1_new',431order: 3432}433});434}435436override run(accessor: ServicesAccessor): Promise<void> {437const hostService = accessor.get(IHostService);438439return hostService.openWindow({ remoteAuthority: null });440}441}442443class BlurAction extends Action2 {444445constructor() {446super({447id: 'workbench.action.blur',448title: localize2('blur', 'Remove keyboard focus from focused element')449});450}451452run(): void {453const activeElement = getActiveElement();454if (isHTMLElement(activeElement)) {455activeElement.blur();456}457}458}459460// --- Actions Registration461462registerAction2(NewWindowAction);463registerAction2(ToggleFullScreenAction);464registerAction2(QuickPickRecentAction);465registerAction2(OpenRecentAction);466registerAction2(ReloadWindowAction);467registerAction2(ShowAboutDialogAction);468registerAction2(BlurAction);469470// --- Commands/Keybindings Registration471472const recentFilesPickerContext = ContextKeyExpr.and(inQuickPickContext, ContextKeyExpr.has(inRecentFilesPickerContextKey));473474const quickPickNavigateNextInRecentFilesPickerId = 'workbench.action.quickOpenNavigateNextInRecentFilesPicker';475KeybindingsRegistry.registerCommandAndKeybindingRule({476id: quickPickNavigateNextInRecentFilesPickerId,477weight: KeybindingWeight.WorkbenchContrib + 50,478handler: getQuickNavigateHandler(quickPickNavigateNextInRecentFilesPickerId, true),479when: recentFilesPickerContext,480primary: KeyMod.CtrlCmd | KeyCode.KeyR,481mac: { primary: KeyMod.WinCtrl | KeyCode.KeyR }482});483484const quickPickNavigatePreviousInRecentFilesPicker = 'workbench.action.quickOpenNavigatePreviousInRecentFilesPicker';485KeybindingsRegistry.registerCommandAndKeybindingRule({486id: quickPickNavigatePreviousInRecentFilesPicker,487weight: KeybindingWeight.WorkbenchContrib + 50,488handler: getQuickNavigateHandler(quickPickNavigatePreviousInRecentFilesPicker, false),489when: recentFilesPickerContext,490primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyR,491mac: { primary: KeyMod.WinCtrl | KeyMod.Shift | KeyCode.KeyR }492});493494CommandsRegistry.registerCommand('workbench.action.toggleConfirmBeforeClose', accessor => {495const configurationService = accessor.get(IConfigurationService);496const setting = configurationService.inspect<'always' | 'keyboardOnly' | 'never'>('window.confirmBeforeClose').userValue;497498return configurationService.updateValue('window.confirmBeforeClose', setting === 'never' ? 'keyboardOnly' : 'never');499});500501// --- Menu Registration502503MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, {504group: 'z_ConfirmClose',505command: {506id: 'workbench.action.toggleConfirmBeforeClose',507title: localize('miConfirmClose', "Confirm Before Close"),508toggled: ContextKeyExpr.notEquals('config.window.confirmBeforeClose', 'never')509},510order: 1,511when: IsWebContext512});513514MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, {515title: localize({ key: 'miOpenRecent', comment: ['&& denotes a mnemonic'] }, "Open &&Recent"),516submenu: MenuId.MenubarRecentMenu,517group: '2_open',518order: 4519});520521522