From f7dde7f949eec423ca0b0326338f6334a484ca16 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Mon, 30 Mar 2020 11:36:46 +0200 Subject: [PATCH] Indicate dirty workspaces in recently opened (#93723) * do not restore folders/workspaces with backups * add method to getDirtyWorkspaces * :lipstick: getRecentlyOpened * identify dirty workspaces * show a dialog * change to one method * fix actions * :lipstick: --- .../platform/backup/electron-main/backup.ts | 16 +- .../backup/electron-main/backupMainService.ts | 51 ++++- .../electron-main/backupMainService.test.ts | 42 +++++ .../electron-main/windowsMainService.ts | 6 +- .../platform/workspaces/common/workspaces.ts | 11 +- .../workspacesHistoryMainService.ts | 9 +- .../electron-main/workspacesService.ts | 20 +- .../workspacesMainService.test.ts | 5 + .../browser/actions/developerActions.ts | 2 +- .../media/{screencast.css => actions.css} | 4 + .../browser/actions/windowActions.ts | 178 ++++++++++++------ .../actions/media/actions.css | 2 +- .../workspaces/browser/workspacesService.ts | 9 + 13 files changed, 276 insertions(+), 79 deletions(-) rename src/vs/workbench/browser/actions/media/{screencast.css => actions.css} (84%) diff --git a/src/vs/platform/backup/electron-main/backup.ts b/src/vs/platform/backup/electron-main/backup.ts index 2b471039ae0..3c314a3b61c 100644 --- a/src/vs/platform/backup/electron-main/backup.ts +++ b/src/vs/platform/backup/electron-main/backup.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; -import { IWorkspaceIdentifier } from 'vs/platform/workspaces/common/workspaces'; +import { IWorkspaceIdentifier, isWorkspaceIdentifier } from 'vs/platform/workspaces/common/workspaces'; import { URI } from 'vs/base/common/uri'; import { IEmptyWindowBackupInfo } from 'vs/platform/backup/node/backup'; @@ -15,6 +15,12 @@ export interface IWorkspaceBackupInfo { remoteAuthority?: string; } +export function isWorkspaceBackupInfo(obj: unknown): obj is IWorkspaceBackupInfo { + const candidate = obj as IWorkspaceBackupInfo; + + return candidate && isWorkspaceIdentifier(candidate.workspace); +} + export interface IBackupMainService { _serviceBrand: undefined; @@ -31,4 +37,12 @@ export interface IBackupMainService { unregisterWorkspaceBackupSync(workspace: IWorkspaceIdentifier): void; unregisterFolderBackupSync(folderUri: URI): void; unregisterEmptyWindowBackupSync(backupFolder: string): void; + + /** + * All folders or workspaces that are known to have + * backups stored. This call is long running because + * it checks for each backup location if any backups + * are stored. + */ + getDirtyWorkspaces(): Promise>; } diff --git a/src/vs/platform/backup/electron-main/backupMainService.ts b/src/vs/platform/backup/electron-main/backupMainService.ts index 910457adf19..022434ce0f5 100644 --- a/src/vs/platform/backup/electron-main/backupMainService.ts +++ b/src/vs/platform/backup/electron-main/backupMainService.ts @@ -9,7 +9,7 @@ import * as path from 'vs/base/common/path'; import * as platform from 'vs/base/common/platform'; import { writeFileSync, writeFile, readFile, readdir, exists, rimraf, rename, RimRafMode } from 'vs/base/node/pfs'; import * as arrays from 'vs/base/common/arrays'; -import { IBackupMainService, IWorkspaceBackupInfo } from 'vs/platform/backup/electron-main/backup'; +import { IBackupMainService, IWorkspaceBackupInfo, isWorkspaceBackupInfo } from 'vs/platform/backup/electron-main/backup'; import { IBackupWorkspacesFormat, IEmptyWindowBackupInfo } from 'vs/platform/backup/node/backup'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; @@ -255,7 +255,7 @@ export class BackupMainService implements IBackupMainService { seenIds.add(workspace.id); const backupPath = this.getBackupPath(workspace.id); - const hasBackups = await this.hasBackups(backupPath); + const hasBackups = await this.doHasBackups(backupPath); // If the workspace has no backups, ignore it if (hasBackups) { @@ -287,7 +287,7 @@ export class BackupMainService implements IBackupMainService { seenIds.add(key); const backupPath = this.getBackupPath(this.getFolderHash(folderURI)); - const hasBackups = await this.hasBackups(backupPath); + const hasBackups = await this.doHasBackups(backupPath); // If the folder has no backups, ignore it if (hasBackups) { @@ -325,7 +325,7 @@ export class BackupMainService implements IBackupMainService { seenIds.add(backupFolder); const backupPath = this.getBackupPath(backupFolder); - if (await this.hasBackups(backupPath)) { + if (await this.doHasBackups(backupPath)) { result.push(backupInfo); } else { await this.deleteStaleBackup(backupPath); @@ -388,7 +388,48 @@ export class BackupMainService implements IBackupMainService { return true; } - private async hasBackups(backupPath: string): Promise { + async getDirtyWorkspaces(): Promise> { + const dirtyWorkspaces: Array = []; + + // Workspaces with backups + for (const workspace of this.rootWorkspaces) { + if ((await this.hasBackups(workspace))) { + dirtyWorkspaces.push(workspace.workspace); + } + } + + // Folders with backups + for (const folder of this.folderWorkspaces) { + if ((await this.hasBackups(folder))) { + dirtyWorkspaces.push(folder); + } + } + + return dirtyWorkspaces; + } + + private hasBackups(backupLocation: IWorkspaceBackupInfo | IEmptyWindowBackupInfo | URI): Promise { + let backupPath: string; + + // Folder + if (URI.isUri(backupLocation)) { + backupPath = this.getBackupPath(this.getFolderHash(backupLocation)); + } + + // Workspace + else if (isWorkspaceBackupInfo(backupLocation)) { + backupPath = this.getBackupPath(backupLocation.workspace.id); + } + + // Empty + else { + backupPath = backupLocation.backupFolder; + } + + return this.doHasBackups(backupPath); + } + + private async doHasBackups(backupPath: string): Promise { try { const backupSchemas = await readdir(backupPath); diff --git a/src/vs/platform/backup/test/electron-main/backupMainService.test.ts b/src/vs/platform/backup/test/electron-main/backupMainService.test.ts index 0bffaa82992..14d6bf48f10 100644 --- a/src/vs/platform/backup/test/electron-main/backupMainService.test.ts +++ b/src/vs/platform/backup/test/electron-main/backupMainService.test.ts @@ -22,6 +22,7 @@ import { IWorkspaceIdentifier } from 'vs/platform/workspaces/common/workspaces'; import { createHash } from 'crypto'; import { getRandomTestPath } from 'vs/base/test/node/testUtils'; import { Schemas } from 'vs/base/common/network'; +import { isEqual } from 'vs/base/common/resources'; suite('BackupMainService', () => { @@ -731,4 +732,45 @@ suite('BackupMainService', () => { } }); }); + + suite('getDirtyWorkspaces', () => { + test('should report if a workspace or folder has backups', async () => { + const folderBackupPath = service.registerFolderBackupSync(fooFile); + + const backupWorkspaceInfo = toWorkspaceBackupInfo(fooFile.fsPath); + const workspaceBackupPath = service.registerWorkspaceBackupSync(backupWorkspaceInfo); + + assert.equal(((await service.getDirtyWorkspaces()).length), 0); + + try { + await pfs.mkdirp(path.join(folderBackupPath, Schemas.file)); + await pfs.mkdirp(path.join(workspaceBackupPath, Schemas.untitled)); + } catch (error) { + // ignore - folder might exist already + } + + assert.equal(((await service.getDirtyWorkspaces()).length), 0); + + fs.writeFileSync(path.join(folderBackupPath, Schemas.file, '594a4a9d82a277a899d4713a5b08f504'), ''); + fs.writeFileSync(path.join(workspaceBackupPath, Schemas.untitled, '594a4a9d82a277a899d4713a5b08f504'), ''); + + const dirtyWorkspaces = await service.getDirtyWorkspaces(); + assert.equal(dirtyWorkspaces.length, 2); + + let found = 0; + for (const dirtyWorkpspace of dirtyWorkspaces) { + if (URI.isUri(dirtyWorkpspace)) { + if (isEqual(fooFile, dirtyWorkpspace)) { + found++; + } + } else { + if (isEqual(backupWorkspaceInfo.workspace.configPath, dirtyWorkpspace.configPath)) { + found++; + } + } + } + + assert.equal(found, 2); + }); + }); }); diff --git a/src/vs/platform/windows/electron-main/windowsMainService.ts b/src/vs/platform/windows/electron-main/windowsMainService.ts index 5129d8a0cf8..8a77432d355 100644 --- a/src/vs/platform/windows/electron-main/windowsMainService.ts +++ b/src/vs/platform/windows/electron-main/windowsMainService.ts @@ -452,14 +452,16 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic } // - // These are windows to restore because of hot-exit or from previous session that would otherwise be lost (only performed once on startup!) + // These are windows to restore because of hot-exit or from previous session (only performed once on startup!) // let workspacesToRestore: IWorkspacePathToOpen[] = []; if (openConfig.initialStartup && !openConfig.cli.extensionDevelopmentPath && !openConfig.cli['disable-restore-windows']) { - // collect from workspaces with hot-exit backups and from previous window session + + // Untitled workspaces are always restored workspacesToRestore = this.workspacesMainService.getUntitledWorkspacesSync(); workspacesToOpen.push(...workspacesToRestore); + // Empty windows with backups are always restored emptyToRestore.push(...this.backupMainService.getEmptyWindowBackupPaths()); } else { emptyToRestore.length = 0; diff --git a/src/vs/platform/workspaces/common/workspaces.ts b/src/vs/platform/workspaces/common/workspaces.ts index b904c106efa..18b5461e2a7 100644 --- a/src/vs/platform/workspaces/common/workspaces.ts +++ b/src/vs/platform/workspaces/common/workspaces.ts @@ -18,7 +18,7 @@ import { toSlashes } from 'vs/base/common/extpath'; import { FormattingOptions } from 'vs/base/common/jsonFormatter'; import { getRemoteAuthority } from 'vs/platform/remote/common/remoteHosts'; import { ILogService } from 'vs/platform/log/common/log'; -import { Event as CommonEvent } from 'vs/base/common/event'; +import { Event } from 'vs/base/common/event'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; export const WORKSPACE_EXTENSION = 'code-workspace'; @@ -31,18 +31,21 @@ export interface IWorkspacesService { _serviceBrand: undefined; - // Management + // Workspaces Management enterWorkspace(path: URI): Promise; createUntitledWorkspace(folders?: IWorkspaceFolderCreationData[], remoteAuthority?: string): Promise; deleteUntitledWorkspace(workspace: IWorkspaceIdentifier): Promise; getWorkspaceIdentifier(workspacePath: URI): Promise; - // History - readonly onRecentlyOpenedChange: CommonEvent; + // Workspaces History + readonly onRecentlyOpenedChange: Event; addRecentlyOpened(recents: IRecent[]): Promise; removeRecentlyOpened(workspaces: URI[]): Promise; clearRecentlyOpened(): Promise; getRecentlyOpened(): Promise; + + // Dirty Workspaces + getDirtyWorkspaces(): Promise>; } export interface IRecentlyOpened { diff --git a/src/vs/platform/workspaces/electron-main/workspacesHistoryMainService.ts b/src/vs/platform/workspaces/electron-main/workspacesHistoryMainService.ts index f88997659d3..85d23eb169a 100644 --- a/src/vs/platform/workspaces/electron-main/workspacesHistoryMainService.ts +++ b/src/vs/platform/workspaces/electron-main/workspacesHistoryMainService.ts @@ -9,7 +9,6 @@ import { IStateService } from 'vs/platform/state/node/state'; import { app, JumpListCategory } from 'electron'; import { ILogService } from 'vs/platform/log/common/log'; import { getBaseLabel, getPathLabel, splitName } from 'vs/base/common/labels'; -import { IPath } from 'vs/platform/windows/common/windows'; import { Event as CommonEvent, Emitter } from 'vs/base/common/event'; import { isWindows, isMacintosh } from 'vs/base/common/platform'; import { IWorkspaceIdentifier, ISingleFolderWorkspaceIdentifier, isSingleFolderWorkspaceIdentifier, IRecentlyOpened, isRecentWorkspace, isRecentFolder, IRecent, isRecentFile, IRecentFolder, IRecentWorkspace, IRecentFile, toStoreData, restoreRecentlyOpened, RecentlyOpenedStorageData } from 'vs/platform/workspaces/common/workspaces'; @@ -24,6 +23,7 @@ import { exists } from 'vs/base/node/pfs'; import { ILifecycleMainService, LifecycleMainPhase } from 'vs/platform/lifecycle/electron-main/lifecycleMainService'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { Disposable } from 'vs/base/common/lifecycle'; +import { ICodeWindow } from 'vs/platform/windows/electron-main/windows'; export const IWorkspacesHistoryMainService = createDecorator('workspacesHistoryMainService'); @@ -34,7 +34,7 @@ export interface IWorkspacesHistoryMainService { readonly onRecentlyOpenedChange: CommonEvent; addRecentlyOpened(recents: IRecent[]): void; - getRecentlyOpened(currentWorkspace?: IWorkspaceIdentifier, currentFolder?: ISingleFolderWorkspaceIdentifier, currentFiles?: IPath[]): IRecentlyOpened; + getRecentlyOpened(include?: ICodeWindow): IRecentlyOpened; removeRecentlyOpened(paths: URI[]): void; clearRecentlyOpened(): void; @@ -241,20 +241,23 @@ export class WorkspacesHistoryMainService extends Disposable implements IWorkspa this._onRecentlyOpenedChange.fire(); } - getRecentlyOpened(currentWorkspace?: IWorkspaceIdentifier, currentFolder?: ISingleFolderWorkspaceIdentifier, currentFiles?: IPath[]): IRecentlyOpened { + getRecentlyOpened(include?: ICodeWindow): IRecentlyOpened { const workspaces: Array = []; const files: IRecentFile[] = []; // Add current workspace to beginning if set + const currentWorkspace = include?.config?.workspace; if (currentWorkspace && !this.workspacesMainService.isUntitledWorkspace(currentWorkspace)) { workspaces.push({ workspace: currentWorkspace }); } + const currentFolder = include?.config?.folderUri; if (currentFolder) { workspaces.push({ folderUri: currentFolder }); } // Add currently files to open to the beginning if any + const currentFiles = include?.config?.filesToOpenOrCreate; if (currentFiles) { for (let currentFile of currentFiles) { const fileUri = currentFile.fileUri; diff --git a/src/vs/platform/workspaces/electron-main/workspacesService.ts b/src/vs/platform/workspaces/electron-main/workspacesService.ts index 3084a7ea969..c5a12d23e58 100644 --- a/src/vs/platform/workspaces/electron-main/workspacesService.ts +++ b/src/vs/platform/workspaces/electron-main/workspacesService.ts @@ -9,6 +9,7 @@ import { URI } from 'vs/base/common/uri'; import { IWorkspacesMainService } from 'vs/platform/workspaces/electron-main/workspacesMainService'; import { IWindowsMainService } from 'vs/platform/windows/electron-main/windows'; import { IWorkspacesHistoryMainService } from 'vs/platform/workspaces/electron-main/workspacesHistoryMainService'; +import { IBackupMainService } from 'vs/platform/backup/electron-main/backup'; export class WorkspacesService implements AddFirstParameterToFunctions /* only methods, not events */, number /* window ID */> { @@ -17,7 +18,8 @@ export class WorkspacesService implements AddFirstParameterToFunctions { - const window = this.windowsMainService.getWindowById(windowId); - if (window?.config) { - return this.workspacesHistoryMainService.getRecentlyOpened(window.config.workspace, window.config.folderUri, window.config.filesToOpenOrCreate); - } - - return this.workspacesHistoryMainService.getRecentlyOpened(); + return this.workspacesHistoryMainService.getRecentlyOpened(this.windowsMainService.getWindowById(windowId)); } async addRecentlyOpened(windowId: number, recents: IRecent[]): Promise { @@ -72,4 +69,13 @@ export class WorkspacesService implements AddFirstParameterToFunctions> { + return this.backupMainService.getDirtyWorkspaces(); + } + + //#endregion } diff --git a/src/vs/platform/workspaces/test/electron-main/workspacesMainService.test.ts b/src/vs/platform/workspaces/test/electron-main/workspacesMainService.test.ts index f460432d840..cb08319cc0f 100644 --- a/src/vs/platform/workspaces/test/electron-main/workspacesMainService.test.ts +++ b/src/vs/platform/workspaces/test/electron-main/workspacesMainService.test.ts @@ -56,6 +56,7 @@ export class TestDialogMainService implements IDialogMainService { } export class TestBackupMainService implements IBackupMainService { + _serviceBrand: undefined; isHotExitEnabled(): boolean { @@ -97,6 +98,10 @@ export class TestBackupMainService implements IBackupMainService { unregisterEmptyWindowBackupSync(backupFolder: string): void { throw new Error('Method not implemented.'); } + + async getDirtyWorkspaces(): Promise<(IWorkspaceIdentifier | URI)[]> { + return []; + } } suite('WorkspacesMainService', () => { diff --git a/src/vs/workbench/browser/actions/developerActions.ts b/src/vs/workbench/browser/actions/developerActions.ts index 3e66fae8bf1..23c29d4c878 100644 --- a/src/vs/workbench/browser/actions/developerActions.ts +++ b/src/vs/workbench/browser/actions/developerActions.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import 'vs/css!./media/screencast'; +import 'vs/css!./media/actions'; import { Action } from 'vs/base/common/actions'; import * as nls from 'vs/nls'; diff --git a/src/vs/workbench/browser/actions/media/screencast.css b/src/vs/workbench/browser/actions/media/actions.css similarity index 84% rename from src/vs/workbench/browser/actions/media/screencast.css rename to src/vs/workbench/browser/actions/media/actions.css index b4b71b36726..cd7a0367ea6 100644 --- a/src/vs/workbench/browser/actions/media/screencast.css +++ b/src/vs/workbench/browser/actions/media/actions.css @@ -3,6 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +.quick-input-list .quick-input-list-entry.has-actions:hover .quick-input-list-entry-action-bar .action-label.dirty-workspace::before { + content: "\ea76"; /* Close icon flips between black dot and "X" for dirty workspaces */ +} + .monaco-workbench .screencast-mouse { position: absolute; border: 2px solid red; diff --git a/src/vs/workbench/browser/actions/windowActions.ts b/src/vs/workbench/browser/actions/windowActions.ts index ca10e3b1cd0..c5e48cdac6e 100644 --- a/src/vs/workbench/browser/actions/windowActions.ts +++ b/src/vs/workbench/browser/actions/windowActions.ts @@ -14,13 +14,13 @@ import { IsFullscreenContext } from 'vs/workbench/browser/contextkeys'; import { IsMacNativeContext, IsDevelopmentContext } from 'vs/platform/contextkey/common/contextkeys'; import { IWorkbenchActionRegistry, Extensions } from 'vs/workbench/common/actions'; import { KeybindingsRegistry, KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; -import { IQuickInputButton, IQuickInputService, IQuickPickSeparator, IKeyMods } from 'vs/platform/quickinput/common/quickInput'; +import { IQuickInputButton, IQuickInputService, IQuickPickSeparator, IKeyMods, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { ILabelService } from 'vs/platform/label/common/label'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { IModelService } from 'vs/editor/common/services/modelService'; import { IModeService } from 'vs/editor/common/services/modeService'; -import { IRecentWorkspace, IRecentFolder, IRecentFile, IRecent, isRecentFolder, isRecentWorkspace, IWorkspacesService } from 'vs/platform/workspaces/common/workspaces'; +import { IRecent, isRecentFolder, isRecentWorkspace, IWorkspacesService, IWorkspaceIdentifier, isWorkspaceIdentifier } from 'vs/platform/workspaces/common/workspaces'; import { URI } from 'vs/base/common/uri'; import { getIconClasses } from 'vs/editor/common/services/getIconClasses'; import { FileKind } from 'vs/platform/files/common/files'; @@ -29,9 +29,15 @@ import { isMacintosh } from 'vs/base/common/platform'; import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; import { inQuickPickContext, getQuickNavigateHandler } from 'vs/workbench/browser/quickaccess'; import { IHostService } from 'vs/workbench/services/host/browser/host'; +import { ResourceMap } from 'vs/base/common/map'; export const inRecentFilesPickerContextKey = 'inRecentFilesPicker'; +interface IRecentlyOpenedPick extends IQuickPickItem { + resource: URI, + openable: IWindowOpenable; +} + abstract class BaseOpenRecentAction extends Action { private readonly removeFromRecentlyOpened: IQuickInputButton = { @@ -39,6 +45,12 @@ abstract class BaseOpenRecentAction extends Action { tooltip: nls.localize('remove', "Remove from Recently Opened") }; + private readonly dirtyRecentlyOpened: IQuickInputButton = { + iconClass: 'dirty-workspace codicon-circle-filled', + tooltip: nls.localize('dirtyRecentlyOpened', "Workspace With Dirty Files"), + alwaysVisible: true + }; + constructor( id: string, label: string, @@ -49,7 +61,8 @@ abstract class BaseOpenRecentAction extends Action { private keybindingService: IKeybindingService, private modelService: IModelService, private modeService: IModeService, - private hostService: IHostService + private hostService: IHostService, + private dialogService: IDialogService ) { super(id, label); } @@ -57,61 +70,53 @@ abstract class BaseOpenRecentAction extends Action { protected abstract isQuickNavigate(): boolean; async run(): Promise { - const { workspaces, files } = await this.workspacesService.getRecentlyOpened(); - - this.openRecent(workspaces, files); - } - - private async openRecent(recentWorkspaces: Array, recentFiles: IRecentFile[]): Promise { - - const toPick = (recent: IRecent, labelService: ILabelService, buttons: IQuickInputButton[] | undefined) => { - let openable: IWindowOpenable | undefined; - let iconClasses: string[]; - let fullLabel: string | undefined; - let resource: URI | undefined; - - // Folder - if (isRecentFolder(recent)) { - resource = recent.folderUri; - iconClasses = getIconClasses(this.modelService, this.modeService, resource, FileKind.FOLDER); - openable = { folderUri: resource }; - fullLabel = recent.label || labelService.getWorkspaceLabel(resource, { verbose: true }); + const recentlyOpened = await this.workspacesService.getRecentlyOpened(); + const dirtyWorkspacesAndFolders = await this.workspacesService.getDirtyWorkspaces(); + + // Identify all folders and workspaces with dirty files + const dirtyFolders = new ResourceMap(); + const dirtyWorkspaces = new ResourceMap(); + for (const dirtyWorkspace of dirtyWorkspacesAndFolders) { + if (URI.isUri(dirtyWorkspace)) { + dirtyFolders.set(dirtyWorkspace, true); + } else { + dirtyWorkspaces.set(dirtyWorkspace.configPath, dirtyWorkspace); } + } - // Workspace - else if (isRecentWorkspace(recent)) { - resource = recent.workspace.configPath; - iconClasses = getIconClasses(this.modelService, this.modeService, resource, FileKind.ROOT_FOLDER); - openable = { workspaceUri: resource }; - fullLabel = recent.label || labelService.getWorkspaceLabel(recent.workspace, { verbose: true }); + // Identify all recently opened folders and workspaces + const recentFolders = new ResourceMap(); + const recentWorkspaces = new ResourceMap(); + for (const recent of recentlyOpened.workspaces) { + if (isRecentFolder(recent)) { + recentFolders.set(recent.folderUri, true); + } else { + recentWorkspaces.set(recent.workspace.configPath, recent.workspace); } + } - // File - else { - resource = recent.fileUri; - iconClasses = getIconClasses(this.modelService, this.modeService, resource, FileKind.FILE); - openable = { fileUri: resource }; - fullLabel = recent.label || labelService.getUriLabel(resource); - } + // Fill in all known recently opened workspaces + const workspacePicks: IRecentlyOpenedPick[] = []; + for (const recent of recentlyOpened.workspaces) { + const isDirty = isRecentFolder(recent) ? dirtyFolders.has(recent.folderUri) : dirtyWorkspaces.has(recent.workspace.configPath); - const { name, parentPath } = splitName(fullLabel); + workspacePicks.push(this.toQuickPick(recent, isDirty)); + } - return { - iconClasses, - label: name, - description: parentPath, - buttons, - openable, - resource - }; - }; + // Fill any backup workspace that is not yet shown at the end + for (const dirtyWorkspaceOrFolder of dirtyWorkspacesAndFolders) { + if (URI.isUri(dirtyWorkspaceOrFolder) && !recentFolders.has(dirtyWorkspaceOrFolder)) { + workspacePicks.push(this.toQuickPick({ folderUri: dirtyWorkspaceOrFolder }, true)); + } else if (isWorkspaceIdentifier(dirtyWorkspaceOrFolder) && !recentWorkspaces.has(dirtyWorkspaceOrFolder.configPath)) { + workspacePicks.push(this.toQuickPick({ workspace: dirtyWorkspaceOrFolder }, true)); + } + } - const workspacePicks = recentWorkspaces.map(workspace => toPick(workspace, this.labelService, !this.isQuickNavigate() ? [this.removeFromRecentlyOpened] : undefined)); - const filePicks = recentFiles.map(p => toPick(p, this.labelService, !this.isQuickNavigate() ? [this.removeFromRecentlyOpened] : undefined)); + const filePicks = recentlyOpened.files.map(p => this.toQuickPick(p, false)); // focus second entry if the first recent workspace is the current workspace - const firstEntry = recentWorkspaces[0]; - let autoFocusSecondEntry: boolean = firstEntry && this.contextService.isCurrentWorkspace(isRecentWorkspace(firstEntry) ? firstEntry.workspace : firstEntry.folderUri); + const firstEntry = recentlyOpened.workspaces[0]; + const autoFocusSecondEntry: boolean = firstEntry && this.contextService.isCurrentWorkspace(isRecentWorkspace(firstEntry) ? firstEntry.workspace : firstEntry.folderUri); let keyMods: IKeyMods | undefined; @@ -127,8 +132,27 @@ abstract class BaseOpenRecentAction extends Action { onKeyMods: mods => keyMods = mods, quickNavigate: this.isQuickNavigate() ? { keybindings: this.keybindingService.lookupKeybindings(this.id) } : undefined, onDidTriggerItemButton: async context => { - await this.workspacesService.removeRecentlyOpened([context.item.resource]); - context.removeItem(); + + // Remove + if (context.button === this.removeFromRecentlyOpened) { + await this.workspacesService.removeRecentlyOpened([context.item.resource]); + context.removeItem(); + } + + // Dirty Workspace + else if (context.button === this.dirtyRecentlyOpened) { + const result = await this.dialogService.confirm({ + type: 'question', + title: nls.localize('dirtyWorkspace', "Workspace with Dirty Files"), + message: nls.localize('dirtyWorkspaceConfirm', "Do you want to open the workspace to review the dirty files?"), + detail: nls.localize('dirtyWorkspaceConfirmDetail', "Workspaces with dirty files cannot be removed until all dirty files have been saved or reverted.") + }); + + if (result.confirmed) { + this.hostService.openWindow([context.item.openable]); + this.quickInputService.cancel(); + } + } } }); @@ -136,6 +160,48 @@ abstract class BaseOpenRecentAction extends Action { return this.hostService.openWindow([pick.openable], { forceNewWindow: keyMods?.ctrlCmd, forceReuseWindow: keyMods?.alt }); } } + + private toQuickPick(recent: IRecent, isDirty: boolean): IRecentlyOpenedPick { + let openable: IWindowOpenable | undefined; + let iconClasses: string[]; + let fullLabel: string | undefined; + let resource: URI | undefined; + + // Folder + if (isRecentFolder(recent)) { + resource = recent.folderUri; + iconClasses = getIconClasses(this.modelService, this.modeService, resource, FileKind.FOLDER); + openable = { folderUri: resource }; + fullLabel = recent.label || this.labelService.getWorkspaceLabel(resource, { verbose: true }); + } + + // Workspace + else if (isRecentWorkspace(recent)) { + resource = recent.workspace.configPath; + iconClasses = getIconClasses(this.modelService, this.modeService, resource, FileKind.ROOT_FOLDER); + openable = { workspaceUri: resource }; + fullLabel = recent.label || this.labelService.getWorkspaceLabel(recent.workspace, { verbose: true }); + } + + // File + else { + resource = recent.fileUri; + iconClasses = getIconClasses(this.modelService, this.modeService, resource, FileKind.FILE); + openable = { fileUri: resource }; + fullLabel = recent.label || this.labelService.getUriLabel(resource); + } + + const { name, parentPath } = splitName(fullLabel); + + return { + iconClasses, + label: name, + description: parentPath, + buttons: isDirty ? [this.dirtyRecentlyOpened] : [this.removeFromRecentlyOpened], + openable, + resource + }; + } } export class OpenRecentAction extends BaseOpenRecentAction { @@ -153,9 +219,10 @@ export class OpenRecentAction extends BaseOpenRecentAction { @IModelService modelService: IModelService, @IModeService modeService: IModeService, @ILabelService labelService: ILabelService, - @IHostService hostService: IHostService + @IHostService hostService: IHostService, + @IDialogService dialogService: IDialogService ) { - super(id, label, workspacesService, quickInputService, contextService, labelService, keybindingService, modelService, modeService, hostService); + super(id, label, workspacesService, quickInputService, contextService, labelService, keybindingService, modelService, modeService, hostService, dialogService); } protected isQuickNavigate(): boolean { @@ -178,9 +245,10 @@ class QuickPickRecentAction extends BaseOpenRecentAction { @IModelService modelService: IModelService, @IModeService modeService: IModeService, @ILabelService labelService: ILabelService, - @IHostService hostService: IHostService + @IHostService hostService: IHostService, + @IDialogService dialogService: IDialogService ) { - super(id, label, workspacesService, quickInputService, contextService, labelService, keybindingService, modelService, modeService, hostService); + super(id, label, workspacesService, quickInputService, contextService, labelService, keybindingService, modelService, modeService, hostService, dialogService); } protected isQuickNavigate(): boolean { diff --git a/src/vs/workbench/electron-browser/actions/media/actions.css b/src/vs/workbench/electron-browser/actions/media/actions.css index ba0563a557e..82096e85995 100644 --- a/src/vs/workbench/electron-browser/actions/media/actions.css +++ b/src/vs/workbench/electron-browser/actions/media/actions.css @@ -4,5 +4,5 @@ *--------------------------------------------------------------------------------------------*/ .quick-input-list .quick-input-list-entry.has-actions:hover .quick-input-list-entry-action-bar .action-label.dirty-window::before { - content: "\ea76"; /* Close icon flips between black dot and "X" for dirty open editors */ + content: "\ea76"; /* Close icon flips between black dot and "X" for dirty windows */ } diff --git a/src/vs/workbench/services/workspaces/browser/workspacesService.ts b/src/vs/workbench/services/workspaces/browser/workspacesService.ts index 268a6673540..4cf00ef6b70 100644 --- a/src/vs/workbench/services/workspaces/browser/workspacesService.ts +++ b/src/vs/workbench/services/workspaces/browser/workspacesService.ts @@ -165,6 +165,15 @@ export class BrowserWorkspacesService extends Disposable implements IWorkspacesS } //#endregion + + + //#region Dirty Workspaces + + async getDirtyWorkspaces(): Promise> { + return []; // Currently not supported in web + } + + //#endregion } registerSingleton(IWorkspacesService, BrowserWorkspacesService, true); -- GitLab