Path: blob/main/src/vs/platform/dialogs/electron-main/dialogMainService.ts
5251 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 electron from 'electron';6import { Queue } from '../../../base/common/async.js';7import { hash } from '../../../base/common/hash.js';8import { mnemonicButtonLabel } from '../../../base/common/labels.js';9import { Disposable, dispose, IDisposable, toDisposable } from '../../../base/common/lifecycle.js';10import { normalizeNFC } from '../../../base/common/normalization.js';11import { isMacintosh, isWindows } from '../../../base/common/platform.js';12import { Promises } from '../../../base/node/pfs.js';13import { localize } from '../../../nls.js';14import { INativeOpenDialogOptions, massageMessageBoxOptions } from '../common/dialogs.js';15import { createDecorator } from '../../instantiation/common/instantiation.js';16import { ILogService } from '../../log/common/log.js';17import { IProductService } from '../../product/common/productService.js';18import { WORKSPACE_FILTER } from '../../workspace/common/workspace.js';1920export const IDialogMainService = createDecorator<IDialogMainService>('dialogMainService');2122export interface IDialogMainService {2324readonly _serviceBrand: undefined;2526pickFileFolder(options: INativeOpenDialogOptions, window?: electron.BrowserWindow): Promise<string[] | undefined>;27pickFolder(options: INativeOpenDialogOptions, window?: electron.BrowserWindow): Promise<string[] | undefined>;28pickFile(options: INativeOpenDialogOptions, window?: electron.BrowserWindow): Promise<string[] | undefined>;29pickWorkspace(options: INativeOpenDialogOptions, window?: electron.BrowserWindow): Promise<string[] | undefined>;3031showMessageBox(options: electron.MessageBoxOptions, window?: electron.BrowserWindow): Promise<electron.MessageBoxReturnValue>;32showSaveDialog(options: electron.SaveDialogOptions, window?: electron.BrowserWindow): Promise<electron.SaveDialogReturnValue>;33showOpenDialog(options: electron.OpenDialogOptions, window?: electron.BrowserWindow): Promise<electron.OpenDialogReturnValue>;34}3536interface IInternalNativeOpenDialogOptions extends INativeOpenDialogOptions {37readonly pickFolders?: boolean;38readonly pickFiles?: boolean;3940readonly title: string;41readonly buttonLabel?: string;42readonly filters?: electron.FileFilter[];43}4445export class DialogMainService implements IDialogMainService {4647declare readonly _serviceBrand: undefined;4849private readonly windowFileDialogLocks = new Map<number, Set<number>>();50private readonly windowDialogQueues = new Map<number, Queue<electron.MessageBoxReturnValue | electron.SaveDialogReturnValue | electron.OpenDialogReturnValue>>();51private readonly noWindowDialogueQueue = new Queue<electron.MessageBoxReturnValue | electron.SaveDialogReturnValue | electron.OpenDialogReturnValue>();5253constructor(54@ILogService private readonly logService: ILogService,55@IProductService private readonly productService: IProductService56) {57}5859pickFileFolder(options: INativeOpenDialogOptions, window?: electron.BrowserWindow): Promise<string[] | undefined> {60return this.doPick({ ...options, pickFolders: true, pickFiles: true, title: localize('open', "Open") }, window);61}6263pickFolder(options: INativeOpenDialogOptions, window?: electron.BrowserWindow): Promise<string[] | undefined> {64let optionsInternal: IInternalNativeOpenDialogOptions = {65...options,66pickFolders: true,67title: localize('openFolder', "Open Folder")68};6970if (isWindows) {71// Due to Windows/Electron issue the labels on Open Folder dialog have no hot keys.72// We can fix this here for the button label, but some other labels remain inaccessible.73// See https://github.com/electron/electron/issues/48631 for more info.74optionsInternal = {75...optionsInternal,76buttonLabel: mnemonicButtonLabel(localize({ key: 'selectFolder', comment: ['&& denotes a mnemonic'] }, "&&Select folder")).withMnemonic77};78}7980return this.doPick(optionsInternal, window);81}8283pickFile(options: INativeOpenDialogOptions, window?: electron.BrowserWindow): Promise<string[] | undefined> {84return this.doPick({ ...options, pickFiles: true, title: localize('openFile', "Open File") }, window);85}8687pickWorkspace(options: INativeOpenDialogOptions, window?: electron.BrowserWindow): Promise<string[] | undefined> {88const title = localize('openWorkspaceTitle', "Open Workspace from File");89const buttonLabel = mnemonicButtonLabel(localize({ key: 'openWorkspace', comment: ['&& denotes a mnemonic'] }, "&&Open")).withMnemonic;90const filters = WORKSPACE_FILTER;9192return this.doPick({ ...options, pickFiles: true, title, filters, buttonLabel }, window);93}9495private async doPick(options: IInternalNativeOpenDialogOptions, window?: electron.BrowserWindow): Promise<string[] | undefined> {9697// Ensure dialog options98const dialogOptions: electron.OpenDialogOptions = {99title: options.title,100buttonLabel: options.buttonLabel,101filters: options.filters,102defaultPath: options.defaultPath103};104105// Ensure properties106if (typeof options.pickFiles === 'boolean' || typeof options.pickFolders === 'boolean') {107dialogOptions.properties = undefined; // let it override based on the booleans108109if (options.pickFiles && options.pickFolders) {110dialogOptions.properties = ['multiSelections', 'openDirectory', 'openFile', 'createDirectory'];111}112}113114if (!dialogOptions.properties) {115dialogOptions.properties = ['multiSelections', options.pickFolders ? 'openDirectory' : 'openFile', 'createDirectory'];116}117118if (isMacintosh) {119dialogOptions.properties.push('treatPackageAsDirectory'); // always drill into .app files120}121122// Show Dialog123const result = await this.showOpenDialog(dialogOptions, (window || electron.BrowserWindow.getFocusedWindow()) ?? undefined);124if (result?.filePaths && result.filePaths.length > 0) {125return result.filePaths;126}127128return undefined;129}130131private getWindowDialogQueue<T extends electron.MessageBoxReturnValue | electron.SaveDialogReturnValue | electron.OpenDialogReturnValue>(window?: electron.BrowserWindow): Queue<T> {132133// Queue message box requests per window so that one can show134// after the other.135if (window) {136let windowDialogQueue = this.windowDialogQueues.get(window.id);137if (!windowDialogQueue) {138windowDialogQueue = new Queue<electron.MessageBoxReturnValue | electron.SaveDialogReturnValue | electron.OpenDialogReturnValue>();139this.windowDialogQueues.set(window.id, windowDialogQueue);140}141142return windowDialogQueue as unknown as Queue<T>;143} else {144return this.noWindowDialogueQueue as unknown as Queue<T>;145}146}147148showMessageBox(rawOptions: electron.MessageBoxOptions, window?: electron.BrowserWindow): Promise<electron.MessageBoxReturnValue> {149return this.getWindowDialogQueue<electron.MessageBoxReturnValue>(window).queue(async () => {150const { options, buttonIndeces } = massageMessageBoxOptions(rawOptions, this.productService);151152let result: electron.MessageBoxReturnValue | undefined = undefined;153if (window) {154result = await electron.dialog.showMessageBox(window, options);155} else {156result = await electron.dialog.showMessageBox(options);157}158159return {160response: buttonIndeces[result.response],161checkboxChecked: result.checkboxChecked162};163});164}165166async showSaveDialog(options: electron.SaveDialogOptions, window?: electron.BrowserWindow): Promise<electron.SaveDialogReturnValue> {167168// Prevent duplicates of the same dialog queueing at the same time169const fileDialogLock = this.acquireFileDialogLock(options, window);170if (!fileDialogLock) {171this.logService.error('[DialogMainService]: file save dialog is already or will be showing for the window with the same configuration');172173return { canceled: true, filePath: '' };174}175176try {177return await this.getWindowDialogQueue<electron.SaveDialogReturnValue>(window).queue(async () => {178let result: electron.SaveDialogReturnValue;179if (window) {180result = await electron.dialog.showSaveDialog(window, options);181} else {182result = await electron.dialog.showSaveDialog(options);183}184185result.filePath = this.normalizePath(result.filePath);186187return result;188});189} finally {190dispose(fileDialogLock);191}192}193194private normalizePath(path: string): string;195private normalizePath(path: string | undefined): string | undefined;196private normalizePath(path: string | undefined): string | undefined {197if (path && isMacintosh) {198path = normalizeNFC(path); // macOS only: normalize paths to NFC form199}200201return path;202}203204private normalizePaths(paths: string[]): string[] {205return paths.map(path => this.normalizePath(path));206}207208async showOpenDialog(options: electron.OpenDialogOptions, window?: electron.BrowserWindow): Promise<electron.OpenDialogReturnValue> {209210// Ensure the path exists (if provided)211if (options.defaultPath) {212const pathExists = await Promises.exists(options.defaultPath);213if (!pathExists) {214options.defaultPath = undefined;215}216}217218// Prevent duplicates of the same dialog queueing at the same time219const fileDialogLock = this.acquireFileDialogLock(options, window);220if (!fileDialogLock) {221this.logService.error('[DialogMainService]: file open dialog is already or will be showing for the window with the same configuration');222223return { canceled: true, filePaths: [] };224}225226try {227return await this.getWindowDialogQueue<electron.OpenDialogReturnValue>(window).queue(async () => {228let result: electron.OpenDialogReturnValue;229if (window) {230result = await electron.dialog.showOpenDialog(window, options);231} else {232result = await electron.dialog.showOpenDialog(options);233}234235result.filePaths = this.normalizePaths(result.filePaths);236237return result;238});239} finally {240dispose(fileDialogLock);241}242}243244private acquireFileDialogLock(options: electron.SaveDialogOptions | electron.OpenDialogOptions, window?: electron.BrowserWindow): IDisposable | undefined {245246// If no window is provided, allow as many dialogs as247// needed since we consider them not modal per window248if (!window) {249return Disposable.None;250}251252// If a window is provided, only allow a single dialog253// at the same time because dialogs are modal and we254// do not want to open one dialog after the other255// (https://github.com/microsoft/vscode/issues/114432)256// we figure this out by `hashing` the configuration257// options for the dialog to prevent duplicates258259this.logService.trace('[DialogMainService]: request to acquire file dialog lock', options);260261let windowFileDialogLocks = this.windowFileDialogLocks.get(window.id);262if (!windowFileDialogLocks) {263windowFileDialogLocks = new Set();264this.windowFileDialogLocks.set(window.id, windowFileDialogLocks);265}266267const optionsHash = hash(options);268if (windowFileDialogLocks.has(optionsHash)) {269return undefined; // prevent duplicates, return270}271272this.logService.trace('[DialogMainService]: new file dialog lock created', options);273274windowFileDialogLocks.add(optionsHash);275276return toDisposable(() => {277this.logService.trace('[DialogMainService]: file dialog lock disposed', options);278279windowFileDialogLocks?.delete(optionsHash);280281// If the window has no more dialog locks, delete it from the set of locks282if (windowFileDialogLocks?.size === 0) {283this.windowFileDialogLocks.delete(window.id);284}285});286}287}288289290