diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 2d6a2224857..8a8db169639 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -67,7 +67,7 @@ import { SharedProcess } from 'vs/platform/sharedProcess/electron-main/sharedPro import { ISignService } from 'vs/platform/sign/common/sign'; import { IStateMainService } from 'vs/platform/state/electron-main/state'; import { StorageDatabaseChannel } from 'vs/platform/storage/electron-main/storageIpc'; -import { IStorageMainService, StorageMainService } from 'vs/platform/storage/electron-main/storageMainService'; +import { GlobalStorageMainService, IGlobalStorageMainService, IStorageMainService, StorageMainService } from 'vs/platform/storage/electron-main/storageMainService'; import { resolveCommonProperties } from 'vs/platform/telemetry/common/commonProperties'; import { ITelemetryService, machineIdKey, TelemetryLevel } from 'vs/platform/telemetry/common/telemetry'; import { TelemetryAppenderClient } from 'vs/platform/telemetry/common/telemetryIpc'; @@ -520,6 +520,7 @@ export class CodeApplication extends Disposable { // Storage services.set(IStorageMainService, new SyncDescriptor(StorageMainService)); + services.set(IGlobalStorageMainService, new SyncDescriptor(GlobalStorageMainService)); // External terminal if (isWindows) { diff --git a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts index dfb8db3b518..ce0068cd413 100644 --- a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts +++ b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts @@ -25,7 +25,7 @@ import { ILogService } from 'vs/platform/log/common/log'; import { IProductService } from 'vs/platform/product/common/productService'; import { asJson, asText, IRequestService, isSuccess } from 'vs/platform/request/common/request'; import { getServiceMachineId } from 'vs/platform/serviceMachineId/common/serviceMachineId'; -import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; +import { IStorageService } from 'vs/platform/storage/common/storage'; import { ITelemetryService, TelemetryLevel } from 'vs/platform/telemetry/common/telemetry'; import { getTelemetryLevel, supportsTelemetry } from 'vs/platform/telemetry/common/telemetryUtils'; @@ -1062,10 +1062,7 @@ export class ExtensionGalleryServiceWithNoStorageService extends AbstractExtensi } } -export async function resolveMarketplaceHeaders(version: string, productService: IProductService, environmentService: IEnvironmentService, configurationService: IConfigurationService, fileService: IFileService, storageService: { - get: (key: string, scope: StorageScope) => string | undefined, - store: (key: string, value: string, scope: StorageScope, target: StorageTarget) => void -} | undefined): Promise<{ [key: string]: string; }> { +export async function resolveMarketplaceHeaders(version: string, productService: IProductService, environmentService: IEnvironmentService, configurationService: IConfigurationService, fileService: IFileService, storageService: IStorageService | undefined): Promise<{ [key: string]: string; }> { const headers: IHeaders = { 'X-Market-Client-Id': `VSCode ${version}`, 'User-Agent': `VSCode ${version} (${productService.nameShort})` diff --git a/src/vs/platform/serviceMachineId/common/serviceMachineId.ts b/src/vs/platform/serviceMachineId/common/serviceMachineId.ts index 99d2fae3c67..e2e17317b5e 100644 --- a/src/vs/platform/serviceMachineId/common/serviceMachineId.ts +++ b/src/vs/platform/serviceMachineId/common/serviceMachineId.ts @@ -7,12 +7,9 @@ import { VSBuffer } from 'vs/base/common/buffer'; import { generateUuid, isUUID } from 'vs/base/common/uuid'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; import { IFileService } from 'vs/platform/files/common/files'; -import { StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; +import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; -export async function getServiceMachineId(environmentService: IEnvironmentService, fileService: IFileService, storageService: { - get: (key: string, scope: StorageScope, fallbackValue?: string | undefined) => string | undefined, - store: (key: string, value: string, scope: StorageScope, target: StorageTarget) => void -} | undefined): Promise { +export async function getServiceMachineId(environmentService: IEnvironmentService, fileService: IFileService, storageService: IStorageService | undefined): Promise { let uuid: string | null = storageService ? storageService.get('storage.serviceMachineId', StorageScope.GLOBAL) || null : null; if (uuid) { return uuid; diff --git a/src/vs/platform/storage/common/storage.ts b/src/vs/platform/storage/common/storage.ts index 88d8146c40c..a36597555f9 100644 --- a/src/vs/platform/storage/common/storage.ts +++ b/src/vs/platform/storage/common/storage.ts @@ -245,7 +245,7 @@ export abstract class AbstractStorageService extends Disposable implements IStor private readonly flushWhenIdleScheduler = this._register(new RunOnceScheduler(() => this.doFlushWhenIdle(), this.options.flushInterval)); private readonly runFlushWhenIdle = this._register(new MutableDisposable()); - constructor(private options: IStorageServiceOptions = { flushInterval: AbstractStorageService.DEFAULT_FLUSH_INTERVAL }) { + constructor(private readonly options: IStorageServiceOptions = { flushInterval: AbstractStorageService.DEFAULT_FLUSH_INTERVAL }) { super(); } diff --git a/src/vs/platform/storage/electron-main/storageMain.ts b/src/vs/platform/storage/electron-main/storageMain.ts index 3a8c84c3d14..21bf291b5e9 100644 --- a/src/vs/platform/storage/electron-main/storageMain.ts +++ b/src/vs/platform/storage/electron-main/storageMain.ts @@ -52,6 +52,12 @@ export interface IStorageMain extends IDisposable { */ readonly whenInit: Promise; + /** + * Provides access to the `IStorage` implementation which will be + * in-memory for as long as the storage has not been initialized. + */ + readonly storage: IStorage; + /** * Required call to ensure the service can be used. */ @@ -93,7 +99,8 @@ abstract class BaseStorageMain extends Disposable implements IStorageMain { private readonly _onDidCloseStorage = this._register(new Emitter()); readonly onDidCloseStorage = this._onDidCloseStorage.event; - private storage: IStorage = new Storage(new InMemoryStorageDatabase()); // storage is in-memory until initialized + private _storage: IStorage = new Storage(new InMemoryStorageDatabase()); // storage is in-memory until initialized + get storage(): IStorage { return this._storage; } private initializePromise: Promise | undefined = undefined; @@ -117,8 +124,8 @@ abstract class BaseStorageMain extends Disposable implements IStorageMain { // Replace our in-memory storage with the real // once as soon as possible without awaiting // the init call. - this.storage.dispose(); - this.storage = storage; + this._storage.dispose(); + this._storage = storage; // Re-emit storage changes via event this._register(storage.onDidChangeStorage(key => this._onDidChangeStorage.fire({ key }))); @@ -157,20 +164,20 @@ abstract class BaseStorageMain extends Disposable implements IStorageMain { protected abstract doCreate(): Promise; - get items(): Map { return this.storage.items; } + get items(): Map { return this._storage.items; } get(key: string, fallbackValue: string): string; get(key: string, fallbackValue?: string): string | undefined; get(key: string, fallbackValue?: string): string | undefined { - return this.storage.get(key, fallbackValue); + return this._storage.get(key, fallbackValue); } set(key: string, value: string | boolean | number | undefined | null): Promise { - return this.storage.set(key, value); + return this._storage.set(key, value); } delete(key: string): Promise { - return this.storage.delete(key); + return this._storage.delete(key); } async close(): Promise { @@ -184,7 +191,7 @@ abstract class BaseStorageMain extends Disposable implements IStorageMain { } // Propagate to storage lib - await this.storage.close(); + await this._storage.close(); // Signal as event this._onDidCloseStorage.fire(); diff --git a/src/vs/platform/storage/electron-main/storageMainService.ts b/src/vs/platform/storage/electron-main/storageMainService.ts index 35ede6a0eab..202c8308058 100644 --- a/src/vs/platform/storage/electron-main/storageMainService.ts +++ b/src/vs/platform/storage/electron-main/storageMainService.ts @@ -5,12 +5,17 @@ import { once } from 'vs/base/common/functional'; import { Disposable } from 'vs/base/common/lifecycle'; +import { IStorage } from 'vs/base/parts/storage/common/storage'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; +import { IEnvironmentMainService } from 'vs/platform/environment/electron-main/environmentMainService'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { ILifecycleMainService, LifecycleMainPhase } from 'vs/platform/lifecycle/electron-main/lifecycleMainService'; import { ILogService } from 'vs/platform/log/common/log'; +import { AbstractStorageService, IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { GlobalStorageMain, IStorageMain, IStorageMainOptions, WorkspaceStorageMain } from 'vs/platform/storage/electron-main/storageMain'; -import { IEmptyWorkspaceIdentifier, ISingleFolderWorkspaceIdentifier, IWorkspaceIdentifier } from 'vs/platform/workspaces/common/workspaces'; +import { IEmptyWorkspaceIdentifier, ISingleFolderWorkspaceIdentifier, IWorkspaceIdentifier, IWorkspaceInitializationPayload } from 'vs/platform/workspaces/common/workspaces'; + +//#region Storage Main Service (intent: make global and workspace storage accessible to windows from main process) export const IStorageMainService = createDecorator('storageMainService'); @@ -20,11 +25,17 @@ export interface IStorageMainService { /** * Provides access to the global storage shared across all windows. + * + * Note: DO NOT use this for reading/writing from the main process! + * Rather use `IGlobalStorageMainService` for that purpose. */ readonly globalStorage: IStorageMain; /** * Provides access to the workspace storage specific to a single window. + * + * Note: DO NOT use this for reading/writing from the main process! + * This is currently not supported. */ workspaceStorage(workspace: IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier | IEmptyWorkspaceIdentifier): IStorageMain; } @@ -131,3 +142,92 @@ export class StorageMainService extends Disposable implements IStorageMainServic //#endregion } + +//#endregion + + +//#region Global Main Storage Service (intent: use global storage from main process) + +export const IGlobalStorageMainService = createDecorator('globalStorageMainService'); + +/** + * A specialized `IStorageService` interface that only allows + * access to the `StorageScope.GLOBAL` scope. + */ +export interface IGlobalStorageMainService extends IStorageService { + + /** + * Important: unlike other storage services in the renderer, the + * main process does not await the storage to be ready, rather + * storage is being initialized while a window opens to reduce + * pressure on startup. + * + * As such, any client wanting to access global storage from the + * main process needs to wait for `whenReady`, otherwise there is + * a chance that the service operates on an in-memory store that + * is not backed by any persistent DB. + */ + readonly whenReady: Promise; + + get(key: string, scope: StorageScope.GLOBAL, fallbackValue: string): string; + get(key: string, scope: StorageScope.GLOBAL, fallbackValue?: string): string | undefined; + + getBoolean(key: string, scope: StorageScope.GLOBAL, fallbackValue: boolean): boolean; + getBoolean(key: string, scope: StorageScope.GLOBAL, fallbackValue?: boolean): boolean | undefined; + + getNumber(key: string, scope: StorageScope.GLOBAL, fallbackValue: number): number; + getNumber(key: string, scope: StorageScope.GLOBAL, fallbackValue?: number): number | undefined; + + store(key: string, value: string | boolean | number | undefined | null, scope: StorageScope.GLOBAL, target: StorageTarget): void; + + remove(key: string, scope: StorageScope.GLOBAL): void; + + keys(scope: StorageScope.GLOBAL, target: StorageTarget): string[]; + + migrate(toWorkspace: IWorkspaceInitializationPayload): never; + + isNew(scope: StorageScope.GLOBAL): boolean; +} + +export class GlobalStorageMainService extends AbstractStorageService implements IGlobalStorageMainService { + + declare readonly _serviceBrand: undefined; + + readonly whenReady = this.storageMainService.globalStorage.whenInit; + + constructor( + @IEnvironmentMainService private readonly environmentMainService: IEnvironmentMainService, + @IStorageMainService private readonly storageMainService: IStorageMainService + ) { + super(); + } + + protected doInitialize(): Promise { + + // global storage is being initialized as part + // of the first window opening, so we do not + // trigger it here but can join it + return this.storageMainService.globalStorage.whenInit; + } + + protected getStorage(scope: StorageScope): IStorage | undefined { + switch (scope) { + case StorageScope.GLOBAL: + return this.storageMainService.globalStorage.storage; + case StorageScope.WORKSPACE: + return undefined; // unsupported from main process + } + } + + protected getLogDetails(scope: StorageScope): string | undefined { + return scope === StorageScope.GLOBAL ? this.environmentMainService.globalStorageHome.fsPath : undefined; + } + + protected override shouldFlushWhenIdle(): boolean { + return false; // not needed here, will be triggered from any window that is opened + } + + migrate(): never { + throw new Error('Migrating storage is unsupported from main process'); + } +} diff --git a/src/vs/platform/windows/electron-main/window.ts b/src/vs/platform/windows/electron-main/window.ts index 1159a1a1342..e41c607fd9a 100644 --- a/src/vs/platform/windows/electron-main/window.ts +++ b/src/vs/platform/windows/electron-main/window.ts @@ -29,7 +29,7 @@ import { ILifecycleMainService } from 'vs/platform/lifecycle/electron-main/lifec import { ILogService } from 'vs/platform/log/common/log'; import { IProductService } from 'vs/platform/product/common/productService'; import { IProtocolMainService } from 'vs/platform/protocol/electron-main/protocol'; -import { IStorageMainService } from 'vs/platform/storage/electron-main/storageMainService'; +import { IGlobalStorageMainService } from 'vs/platform/storage/electron-main/storageMainService'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { ThemeIcon } from 'vs/platform/theme/common/themeService'; import { IThemeMainService } from 'vs/platform/theme/electron-main/themeMainService'; @@ -149,7 +149,7 @@ export class CodeWindow extends Disposable implements ICodeWindow { @ILogService private readonly logService: ILogService, @IEnvironmentMainService private readonly environmentMainService: IEnvironmentMainService, @IFileService private readonly fileService: IFileService, - @IStorageMainService private readonly storageMainService: IStorageMainService, + @IGlobalStorageMainService private readonly globalStorageMainService: IGlobalStorageMainService, @IConfigurationService private readonly configurationService: IConfigurationService, @IThemeMainService private readonly themeMainService: IThemeMainService, @IWorkspacesManagementMainService private readonly workspacesManagementMainService: IWorkspacesManagementMainService, @@ -544,10 +544,7 @@ export class CodeWindow extends Disposable implements ICodeWindow { private marketplaceHeadersPromise: Promise | undefined; private getMarketplaceHeaders(): Promise { if (!this.marketplaceHeadersPromise) { - this.marketplaceHeadersPromise = resolveMarketplaceHeaders(this.productService.version, this.productService, this.environmentMainService, this.configurationService, this.fileService, { - get: key => this.storageMainService.globalStorage.get(key), - store: (key, value) => this.storageMainService.globalStorage.set(key, value) - }); + this.marketplaceHeadersPromise = resolveMarketplaceHeaders(this.productService.version, this.productService, this.environmentMainService, this.configurationService, this.fileService, this.globalStorageMainService); } return this.marketplaceHeadersPromise; } diff --git a/src/vs/platform/workspaces/electron-main/workspacesHistoryMainService.ts b/src/vs/platform/workspaces/electron-main/workspacesHistoryMainService.ts index 2bfe5a5ee6d..1efe127b9ca 100644 --- a/src/vs/platform/workspaces/electron-main/workspacesHistoryMainService.ts +++ b/src/vs/platform/workspaces/electron-main/workspacesHistoryMainService.ts @@ -19,6 +19,8 @@ import { createDecorator } from 'vs/platform/instantiation/common/instantiation' import { ILifecycleMainService, LifecycleMainPhase } from 'vs/platform/lifecycle/electron-main/lifecycleMainService'; import { ILogService } from 'vs/platform/log/common/log'; import { IStateMainService } from 'vs/platform/state/electron-main/state'; +import { StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; +import { IGlobalStorageMainService } from 'vs/platform/storage/electron-main/storageMainService'; import { ICodeWindow } from 'vs/platform/windows/electron-main/windows'; import { IRecent, IRecentFile, IRecentFolder, IRecentlyOpened, IRecentWorkspace, isRecentFile, isRecentFolder, isRecentWorkspace, isSingleFolderWorkspaceIdentifier, isWorkspaceIdentifier, IWorkspaceIdentifier, RecentlyOpenedStorageData, restoreRecentlyOpened, toStoreData, WORKSPACE_EXTENSION } from 'vs/platform/workspaces/common/workspaces'; import { IWorkspacesManagementMainService } from 'vs/platform/workspaces/electron-main/workspacesManagementMainService'; @@ -31,43 +33,31 @@ export interface IWorkspacesHistoryMainService { readonly onDidChangeRecentlyOpened: CommonEvent; - addRecentlyOpened(recents: IRecent[]): void; - getRecentlyOpened(include?: ICodeWindow): IRecentlyOpened; - removeRecentlyOpened(paths: URI[]): void; - clearRecentlyOpened(): void; - - updateWindowsJumpList(): void; + addRecentlyOpened(recents: IRecent[]): Promise; + getRecentlyOpened(include?: ICodeWindow): Promise; + removeRecentlyOpened(paths: URI[]): Promise; + clearRecentlyOpened(): Promise; } export class WorkspacesHistoryMainService extends Disposable implements IWorkspacesHistoryMainService { private static readonly MAX_TOTAL_RECENT_ENTRIES = 500; - private static readonly MAX_MACOS_DOCK_RECENT_WORKSPACES = 7; // prefer higher number of workspaces... - private static readonly MAX_MACOS_DOCK_RECENT_ENTRIES_TOTAL = 10; // ...over number of files + private static readonly RECENTLY_OPENED_STORAGE_KEY = 'history.recentlyOpenedPathsList'; - private static readonly MAX_WINDOWS_JUMP_LIST_ENTRIES = 7; - - // Exclude some very common files from the dock/taskbar - private static readonly COMMON_FILES_FILTER = [ - 'COMMIT_EDITMSG', - 'MERGE_MSG' - ]; - - private static readonly recentlyOpenedStorageKey = 'openedPathsList'; + private static readonly legacyRecentlyOpenedStorageKey = 'openedPathsList'; declare readonly _serviceBrand: undefined; private readonly _onDidChangeRecentlyOpened = this._register(new Emitter()); - readonly onDidChangeRecentlyOpened: CommonEvent = this._onDidChangeRecentlyOpened.event; - - private readonly macOSRecentDocumentsUpdater = this._register(new ThrottledDelayer(800)); + readonly onDidChangeRecentlyOpened = this._onDidChangeRecentlyOpened.event; constructor( @IStateMainService private readonly stateMainService: IStateMainService, @ILogService private readonly logService: ILogService, @IWorkspacesManagementMainService private readonly workspacesManagementMainService: IWorkspacesManagementMainService, - @ILifecycleMainService private readonly lifecycleMainService: ILifecycleMainService + @ILifecycleMainService private readonly lifecycleMainService: ILifecycleMainService, + @IGlobalStorageMainService private readonly globalStorageMainService: IGlobalStorageMainService ) { super(); @@ -83,16 +73,9 @@ export class WorkspacesHistoryMainService extends Disposable implements IWorkspa this._register(this.workspacesManagementMainService.onDidEnterWorkspace(event => this.addRecentlyOpened([{ workspace: event.workspace, remoteAuthority: event.window.remoteAuthority }]))); } - private handleWindowsJumpList(): void { - if (!isWindows) { - return; // only on windows - } + //#region Workspaces History - this.updateWindowsJumpList(); - this._register(this.onDidChangeRecentlyOpened(() => this.updateWindowsJumpList())); - } - - addRecentlyOpened(recentToAdd: IRecent[]): void { + async addRecentlyOpened(recentToAdd: IRecent[]): Promise { const workspaces: Array = []; const files: IRecentFile[] = []; @@ -128,7 +111,7 @@ export class WorkspacesHistoryMainService extends Disposable implements IWorkspa } } - this.addEntriesFromStorage(workspaces, files); + await this.addEntriesFromStorage(workspaces, files); if (workspaces.length > WorkspacesHistoryMainService.MAX_TOTAL_RECENT_ENTRIES) { workspaces.length = WorkspacesHistoryMainService.MAX_TOTAL_RECENT_ENTRIES; @@ -138,7 +121,7 @@ export class WorkspacesHistoryMainService extends Disposable implements IWorkspa files.length = WorkspacesHistoryMainService.MAX_TOTAL_RECENT_ENTRIES; } - this.saveRecentlyOpened({ workspaces, files }); + await this.saveRecentlyOpened({ workspaces, files }); this._onDidChangeRecentlyOpened.fire(); // Schedule update to recent documents on macOS dock @@ -147,7 +130,7 @@ export class WorkspacesHistoryMainService extends Disposable implements IWorkspa } } - removeRecentlyOpened(recentToRemove: URI[]): void { + async removeRecentlyOpened(recentToRemove: URI[]): Promise { const keep = (recent: IRecent) => { const uri = this.location(recent); for (const resourceToRemove of recentToRemove) { @@ -159,12 +142,12 @@ export class WorkspacesHistoryMainService extends Disposable implements IWorkspa return true; }; - const mru = this.getRecentlyOpened(); + const mru = await this.getRecentlyOpened(); const workspaces = mru.workspaces.filter(keep); const files = mru.files.filter(keep); if (workspaces.length !== mru.workspaces.length || files.length !== mru.files.length) { - this.saveRecentlyOpened({ files, workspaces }); + await this.saveRecentlyOpened({ files, workspaces }); this._onDidChangeRecentlyOpened.fire(); // Schedule update to recent documents on macOS dock @@ -174,74 +157,15 @@ export class WorkspacesHistoryMainService extends Disposable implements IWorkspa } } - private async updateMacOSRecentDocuments(): Promise { - if (!isMacintosh) { - return; - } - - // We clear all documents first to ensure an up-to-date view on the set. Since entries - // can get deleted on disk, this ensures that the list is always valid - app.clearRecentDocuments(); - - const mru = this.getRecentlyOpened(); - - // Collect max-N recent workspaces that are known to exist - const workspaceEntries: string[] = []; - let entries = 0; - for (let i = 0; i < mru.workspaces.length && entries < WorkspacesHistoryMainService.MAX_MACOS_DOCK_RECENT_WORKSPACES; i++) { - const loc = this.location(mru.workspaces[i]); - if (loc.scheme === Schemas.file) { - const workspacePath = originalFSPath(loc); - if (await Promises.exists(workspacePath)) { - workspaceEntries.push(workspacePath); - entries++; - } - } - } - - // Collect max-N recent files that are known to exist - const fileEntries: string[] = []; - for (let i = 0; i < mru.files.length && entries < WorkspacesHistoryMainService.MAX_MACOS_DOCK_RECENT_ENTRIES_TOTAL; i++) { - const loc = this.location(mru.files[i]); - if (loc.scheme === Schemas.file) { - const filePath = originalFSPath(loc); - if ( - WorkspacesHistoryMainService.COMMON_FILES_FILTER.includes(basename(loc)) || // skip some well known file entries - workspaceEntries.includes(filePath) // prefer a workspace entry over a file entry (e.g. for .code-workspace) - ) { - continue; - } - - if (await Promises.exists(filePath)) { - fileEntries.push(filePath); - entries++; - } - } - } - - // The apple guidelines (https://developer.apple.com/design/human-interface-guidelines/macos/menus/menu-anatomy/) - // explain that most recent entries should appear close to the interaction by the user (e.g. close to the - // mouse click). Most native macOS applications that add recent documents to the dock, show the most recent document - // to the bottom (because the dock menu is not appearing from top to bottom, but from the bottom to the top). As such - // we fill in the entries in reverse order so that the most recent shows up at the bottom of the menu. - // - // On top of that, the maximum number of documents can be configured by the user (defaults to 10). To ensure that - // we are not failing to show the most recent entries, we start by adding files first (in reverse order of recency) - // and then add folders (in reverse order of recency). Given that strategy, we can ensure that the most recent - // N folders are always appearing, even if the limit is low (https://github.com/microsoft/vscode/issues/74788) - fileEntries.reverse().forEach(fileEntry => app.addRecentDocument(fileEntry)); - workspaceEntries.reverse().forEach(workspaceEntry => app.addRecentDocument(workspaceEntry)); - } - - clearRecentlyOpened(): void { - this.saveRecentlyOpened({ workspaces: [], files: [] }); + async clearRecentlyOpened(): Promise { + await this.saveRecentlyOpened({ workspaces: [], files: [] }); app.clearRecentDocuments(); // Event this._onDidChangeRecentlyOpened.fire(); } - getRecentlyOpened(include?: ICodeWindow): IRecentlyOpened { + async getRecentlyOpened(include?: ICodeWindow): Promise { const workspaces: Array = []; const files: IRecentFile[] = []; @@ -266,15 +190,15 @@ export class WorkspacesHistoryMainService extends Disposable implements IWorkspa } } - this.addEntriesFromStorage(workspaces, files); + await this.addEntriesFromStorage(workspaces, files); return { workspaces, files }; } - private addEntriesFromStorage(workspaces: Array, files: IRecentFile[]) { + private async addEntriesFromStorage(workspaces: Array, files: IRecentFile[]): Promise { // Get from storage - let recents = this.getRecentlyOpenedFromStorage(); + let recents = await this.getRecentlyOpenedFromStorage(); for (let recent of recents.workspaces) { let index = isRecentFolder(recent) ? this.indexOfFolder(workspaces, recent.folderUri) : this.indexOfWorkspace(workspaces, recent.workspace); if (index >= 0) { @@ -294,19 +218,96 @@ export class WorkspacesHistoryMainService extends Disposable implements IWorkspa } } - private getRecentlyOpenedFromStorage(): IRecentlyOpened { - const storedRecents = this.stateMainService.getItem(WorkspacesHistoryMainService.recentlyOpenedStorageKey); + private async getRecentlyOpenedFromStorage(): Promise { - return restoreRecentlyOpened(storedRecents, this.logService); + // Wait for global storage to be ready + await this.globalStorageMainService.whenReady; + + let storedRecentlyOpened: object | undefined = undefined; + + // First try with storage service + const storedRecentlyOpenedRaw = this.globalStorageMainService.get(WorkspacesHistoryMainService.RECENTLY_OPENED_STORAGE_KEY, StorageScope.GLOBAL); + if (typeof storedRecentlyOpenedRaw === 'string') { + try { + storedRecentlyOpened = JSON.parse(storedRecentlyOpenedRaw); + } catch (error) { + this.logService.error('Unexpected error parsing opened paths list', error); + } + } + + // Fallback to state service (TODO@bpasero remove me eventually) + else { + storedRecentlyOpened = this.stateMainService.getItem(WorkspacesHistoryMainService.legacyRecentlyOpenedStorageKey); + if (storedRecentlyOpened) { + this.stateMainService.removeItem(WorkspacesHistoryMainService.legacyRecentlyOpenedStorageKey); + this.globalStorageMainService.store(WorkspacesHistoryMainService.RECENTLY_OPENED_STORAGE_KEY, JSON.stringify(storedRecentlyOpened), StorageScope.GLOBAL, StorageTarget.MACHINE); + } + } + + return restoreRecentlyOpened(storedRecentlyOpened, this.logService); } - private saveRecentlyOpened(recent: IRecentlyOpened): void { - const serialized = toStoreData(recent); + private async saveRecentlyOpened(recent: IRecentlyOpened): Promise { - this.stateMainService.setItem(WorkspacesHistoryMainService.recentlyOpenedStorageKey, serialized); + // Wait for global storage to be ready + await this.globalStorageMainService.whenReady; + + // Store in global storage (but do not sync since this is mainly local paths) + this.globalStorageMainService.store(WorkspacesHistoryMainService.RECENTLY_OPENED_STORAGE_KEY, JSON.stringify(toStoreData(recent)), StorageScope.GLOBAL, StorageTarget.MACHINE); } - updateWindowsJumpList(): void { + private location(recent: IRecent): URI { + if (isRecentFolder(recent)) { + return recent.folderUri; + } + + if (isRecentFile(recent)) { + return recent.fileUri; + } + + return recent.workspace.configPath; + } + + private indexOfWorkspace(recents: IRecent[], candidate: IWorkspaceIdentifier): number { + return recents.findIndex(recent => isRecentWorkspace(recent) && recent.workspace.id === candidate.id); + } + + private indexOfFolder(recents: IRecent[], candidate: URI): number { + return recents.findIndex(recent => isRecentFolder(recent) && extUriBiasedIgnorePathCase.isEqual(recent.folderUri, candidate)); + } + + private indexOfFile(recents: IRecentFile[], candidate: URI): number { + return recents.findIndex(recent => extUriBiasedIgnorePathCase.isEqual(recent.fileUri, candidate)); + } + + //#endregion + + + //#region macOS Dock / Windows JumpList + + private static readonly MAX_MACOS_DOCK_RECENT_WORKSPACES = 7; // prefer higher number of workspaces... + private static readonly MAX_MACOS_DOCK_RECENT_ENTRIES_TOTAL = 10; // ...over number of files + + private static readonly MAX_WINDOWS_JUMP_LIST_ENTRIES = 7; + + // Exclude some very common files from the dock/taskbar + private static readonly COMMON_FILES_FILTER = [ + 'COMMIT_EDITMSG', + 'MERGE_MSG' + ]; + + private readonly macOSRecentDocumentsUpdater = this._register(new ThrottledDelayer(800)); + + private async handleWindowsJumpList(): Promise { + if (!isWindows) { + return; // only on windows + } + + await this.updateWindowsJumpList(); + this._register(this.onDidChangeRecentlyOpened(() => this.updateWindowsJumpList())); + } + + private async updateWindowsJumpList(): Promise { if (!isWindows) { return; // only on windows } @@ -330,7 +331,7 @@ export class WorkspacesHistoryMainService extends Disposable implements IWorkspa }); // Recent Workspaces - if (this.getRecentlyOpened().workspaces.length > 0) { + if ((await this.getRecentlyOpened()).workspaces.length > 0) { // The user might have meanwhile removed items from the jump list and we have to respect that // so we need to update our list of recent paths with the choice of the user to not add them again @@ -346,11 +347,11 @@ export class WorkspacesHistoryMainService extends Disposable implements IWorkspa } } } - this.removeRecentlyOpened(toRemove); + await this.removeRecentlyOpened(toRemove); // Add entries let hasWorkspaces = false; - const items: JumpListItem[] = coalesce(this.getRecentlyOpened().workspaces.slice(0, WorkspacesHistoryMainService.MAX_WINDOWS_JUMP_LIST_ENTRIES).map(recent => { + const items: JumpListItem[] = coalesce((await this.getRecentlyOpened()).workspaces.slice(0, WorkspacesHistoryMainService.MAX_WINDOWS_JUMP_LIST_ENTRIES).map(recent => { const workspace = isRecentWorkspace(recent) ? recent.workspace : recent.folderUri; const { title, description } = this.getWindowsJumpListLabel(workspace, recent.label); @@ -424,27 +425,64 @@ export class WorkspacesHistoryMainService extends Disposable implements IWorkspa return uri.scheme === 'file' ? normalizeDriveLetter(uri.fsPath) : uri.toString(); } - private location(recent: IRecent): URI { - if (isRecentFolder(recent)) { - return recent.folderUri; + private async updateMacOSRecentDocuments(): Promise { + if (!isMacintosh) { + return; } - if (isRecentFile(recent)) { - return recent.fileUri; + // We clear all documents first to ensure an up-to-date view on the set. Since entries + // can get deleted on disk, this ensures that the list is always valid + app.clearRecentDocuments(); + + const mru = await this.getRecentlyOpened(); + + // Collect max-N recent workspaces that are known to exist + const workspaceEntries: string[] = []; + let entries = 0; + for (let i = 0; i < mru.workspaces.length && entries < WorkspacesHistoryMainService.MAX_MACOS_DOCK_RECENT_WORKSPACES; i++) { + const loc = this.location(mru.workspaces[i]); + if (loc.scheme === Schemas.file) { + const workspacePath = originalFSPath(loc); + if (await Promises.exists(workspacePath)) { + workspaceEntries.push(workspacePath); + entries++; + } + } } - return recent.workspace.configPath; + // Collect max-N recent files that are known to exist + const fileEntries: string[] = []; + for (let i = 0; i < mru.files.length && entries < WorkspacesHistoryMainService.MAX_MACOS_DOCK_RECENT_ENTRIES_TOTAL; i++) { + const loc = this.location(mru.files[i]); + if (loc.scheme === Schemas.file) { + const filePath = originalFSPath(loc); + if ( + WorkspacesHistoryMainService.COMMON_FILES_FILTER.includes(basename(loc)) || // skip some well known file entries + workspaceEntries.includes(filePath) // prefer a workspace entry over a file entry (e.g. for .code-workspace) + ) { + continue; + } + + if (await Promises.exists(filePath)) { + fileEntries.push(filePath); + entries++; + } + } + } + + // The apple guidelines (https://developer.apple.com/design/human-interface-guidelines/macos/menus/menu-anatomy/) + // explain that most recent entries should appear close to the interaction by the user (e.g. close to the + // mouse click). Most native macOS applications that add recent documents to the dock, show the most recent document + // to the bottom (because the dock menu is not appearing from top to bottom, but from the bottom to the top). As such + // we fill in the entries in reverse order so that the most recent shows up at the bottom of the menu. + // + // On top of that, the maximum number of documents can be configured by the user (defaults to 10). To ensure that + // we are not failing to show the most recent entries, we start by adding files first (in reverse order of recency) + // and then add folders (in reverse order of recency). Given that strategy, we can ensure that the most recent + // N folders are always appearing, even if the limit is low (https://github.com/microsoft/vscode/issues/74788) + fileEntries.reverse().forEach(fileEntry => app.addRecentDocument(fileEntry)); + workspaceEntries.reverse().forEach(workspaceEntry => app.addRecentDocument(workspaceEntry)); } - private indexOfWorkspace(arr: IRecent[], candidate: IWorkspaceIdentifier): number { - return arr.findIndex(workspace => isRecentWorkspace(workspace) && workspace.workspace.id === candidate.id); - } - - private indexOfFolder(arr: IRecent[], candidate: URI): number { - return arr.findIndex(folder => isRecentFolder(folder) && extUriBiasedIgnorePathCase.isEqual(folder.folderUri, candidate)); - } - - private indexOfFile(arr: IRecentFile[], candidate: URI): number { - return arr.findIndex(file => extUriBiasedIgnorePathCase.isEqual(file.fileUri, candidate)); - } + //#endregion } diff --git a/src/vs/platform/workspaces/electron-main/workspacesMainService.ts b/src/vs/platform/workspaces/electron-main/workspacesMainService.ts index e9b502b0148..2642483d390 100644 --- a/src/vs/platform/workspaces/electron-main/workspacesMainService.ts +++ b/src/vs/platform/workspaces/electron-main/workspacesMainService.ts @@ -52,19 +52,19 @@ export class WorkspacesMainService implements AddFirstParameterToFunctions { + getRecentlyOpened(windowId: number): Promise { return this.workspacesHistoryMainService.getRecentlyOpened(this.windowsMainService.getWindowById(windowId)); } - async addRecentlyOpened(windowId: number, recents: IRecent[]): Promise { + addRecentlyOpened(windowId: number, recents: IRecent[]): Promise { return this.workspacesHistoryMainService.addRecentlyOpened(recents); } - async removeRecentlyOpened(windowId: number, paths: URI[]): Promise { + removeRecentlyOpened(windowId: number, paths: URI[]): Promise { return this.workspacesHistoryMainService.removeRecentlyOpened(paths); } - async clearRecentlyOpened(windowId: number): Promise { + clearRecentlyOpened(windowId: number): Promise { return this.workspacesHistoryMainService.clearRecentlyOpened(); }