Path: blob/main/src/vs/workbench/services/dialogs/browser/abstractFileDialogService.ts
5240 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 * as nls from '../../../../nls.js';6import { IWindowOpenable, isWorkspaceToOpen, isFileToOpen } from '../../../../platform/window/common/window.js';7import { IPickAndOpenOptions, ISaveDialogOptions, IOpenDialogOptions, FileFilter, IFileDialogService, IDialogService, ConfirmResult, getFileNamesMessage } from '../../../../platform/dialogs/common/dialogs.js';8import { isSavedWorkspace, isTemporaryWorkspace, IWorkspaceContextService, WorkbenchState, WORKSPACE_EXTENSION } from '../../../../platform/workspace/common/workspace.js';9import { IHistoryService } from '../../history/common/history.js';10import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js';11import { URI } from '../../../../base/common/uri.js';12import * as resources from '../../../../base/common/resources.js';13import { isAbsolute as localPathIsAbsolute, normalize as localPathNormalize } from '../../../../base/common/path.js';14import { IInstantiationService, } from '../../../../platform/instantiation/common/instantiation.js';15import { ISimpleFileDialog, SimpleFileDialog } from './simpleFileDialog.js';16import { IWorkspacesService } from '../../../../platform/workspaces/common/workspaces.js';17import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';18import { IFileService } from '../../../../platform/files/common/files.js';19import { IOpenerService } from '../../../../platform/opener/common/opener.js';20import { IHostService } from '../../host/browser/host.js';21import Severity from '../../../../base/common/severity.js';22import { coalesce, distinct } from '../../../../base/common/arrays.js';23import { trim } from '../../../../base/common/strings.js';24import { ILanguageService } from '../../../../editor/common/languages/language.js';25import { ILabelService } from '../../../../platform/label/common/label.js';26import { IPathService } from '../../path/common/pathService.js';27import { Schemas } from '../../../../base/common/network.js';28import { PLAINTEXT_EXTENSION } from '../../../../editor/common/languages/modesRegistry.js';29import { ICommandService } from '../../../../platform/commands/common/commands.js';30import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';31import { IEditorService } from '../../editor/common/editorService.js';32import { EditorOpenSource } from '../../../../platform/editor/common/editor.js';33import { ILogService } from '../../../../platform/log/common/log.js';3435export abstract class AbstractFileDialogService implements IFileDialogService {3637declare readonly _serviceBrand: undefined;3839constructor(40@IHostService protected readonly hostService: IHostService,41@IWorkspaceContextService protected readonly contextService: IWorkspaceContextService,42@IHistoryService protected readonly historyService: IHistoryService,43@IWorkbenchEnvironmentService protected readonly environmentService: IWorkbenchEnvironmentService,44@IInstantiationService protected readonly instantiationService: IInstantiationService,45@IConfigurationService protected readonly configurationService: IConfigurationService,46@IFileService protected readonly fileService: IFileService,47@IOpenerService protected readonly openerService: IOpenerService,48@IDialogService protected readonly dialogService: IDialogService,49@ILanguageService private readonly languageService: ILanguageService,50@IWorkspacesService private readonly workspacesService: IWorkspacesService,51@ILabelService private readonly labelService: ILabelService,52@IPathService private readonly pathService: IPathService,53@ICommandService protected readonly commandService: ICommandService,54@IEditorService protected readonly editorService: IEditorService,55@ICodeEditorService protected readonly codeEditorService: ICodeEditorService,56@ILogService private readonly logService: ILogService57) { }5859async defaultFilePath(schemeFilter = this.getSchemeFilterForWindow(), authorityFilter = this.getAuthorityFilterForWindow()): Promise<URI> {6061// Check for last active file first...62let candidate = this.historyService.getLastActiveFile(schemeFilter, authorityFilter);6364// ...then for last active file root65if (!candidate) {66candidate = this.historyService.getLastActiveWorkspaceRoot(schemeFilter, authorityFilter);67if (candidate) {68this.logService.debug(`[FileDialogService] Default file path using last active workspace root: ${candidate}`);69}70} else {71this.logService.debug(`[FileDialogService] Default file path using parent of last active file: ${candidate}`);72candidate = resources.dirname(candidate);73}7475if (!candidate) {76candidate = await this.preferredHome(schemeFilter);77this.logService.debug(`[FileDialogService] Default file path using preferred home: ${candidate}`);78}7980return candidate;81}8283async defaultFolderPath(schemeFilter = this.getSchemeFilterForWindow(), authorityFilter = this.getAuthorityFilterForWindow()): Promise<URI> {8485// Check for last active file root first...86let candidate = this.historyService.getLastActiveWorkspaceRoot(schemeFilter, authorityFilter);8788// ...then for last active file89if (!candidate) {90candidate = this.historyService.getLastActiveFile(schemeFilter, authorityFilter);91if (candidate) {92this.logService.debug(`[FileDialogService] Default folder path using parent of last active file: ${candidate}`);93}94} else {95this.logService.debug(`[FileDialogService] Default folder path using last active workspace root: ${candidate}`);96}9798if (!candidate) {99const preferredHome = await this.preferredHome(schemeFilter);100this.logService.debug(`[FileDialogService] Default folder path using preferred home: ${preferredHome}`);101return preferredHome;102}103104return resources.dirname(candidate);105}106107async preferredHome(schemeFilter = this.getSchemeFilterForWindow()): Promise<URI> {108const preferLocal = schemeFilter === Schemas.file;109const preferredHomeConfig = this.configurationService.inspect<string>('files.dialog.defaultPath');110const preferredHomeCandidate = preferLocal ? preferredHomeConfig.userLocalValue : preferredHomeConfig.userRemoteValue;111this.logService.debug(`[FileDialogService] Preferred home: preferLocal=${preferLocal}, userLocalValue=${preferredHomeConfig.userLocalValue}, userRemoteValue=${preferredHomeConfig.userRemoteValue}`);112if (preferredHomeCandidate) {113const isPreferredHomeCandidateAbsolute = preferLocal ? localPathIsAbsolute(preferredHomeCandidate) : (await this.pathService.path).isAbsolute(preferredHomeCandidate);114if (isPreferredHomeCandidateAbsolute) {115const preferredHomeNormalized = preferLocal ? localPathNormalize(preferredHomeCandidate) : (await this.pathService.path).normalize(preferredHomeCandidate);116const preferredHome = resources.toLocalResource(await this.pathService.fileURI(preferredHomeNormalized), this.environmentService.remoteAuthority, this.pathService.defaultUriScheme);117if (await this.fileService.exists(preferredHome)) {118this.logService.debug(`[FileDialogService] Preferred home using files.dialog.defaultPath setting: ${preferredHome}`);119return preferredHome;120}121this.logService.debug(`[FileDialogService] Preferred home files.dialog.defaultPath path does not exist: ${preferredHome}`);122} else {123this.logService.debug(`[FileDialogService] Preferred home files.dialog.defaultPath is not absolute: ${preferredHomeCandidate}`);124}125}126127const userHome = this.pathService.userHome({ preferLocal });128this.logService.debug(`[FileDialogService] Preferred home using user home: ${userHome}`);129return userHome;130}131132async defaultWorkspacePath(schemeFilter = this.getSchemeFilterForWindow()): Promise<URI> {133let defaultWorkspacePath: URI | undefined;134135// Check for current workspace config file first...136if (this.contextService.getWorkbenchState() === WorkbenchState.WORKSPACE) {137const configuration = this.contextService.getWorkspace().configuration;138if (configuration?.scheme === schemeFilter && isSavedWorkspace(configuration, this.environmentService) && !isTemporaryWorkspace(configuration)) {139defaultWorkspacePath = resources.dirname(configuration);140}141}142143// ...then fallback to default file path144if (!defaultWorkspacePath) {145defaultWorkspacePath = await this.defaultFilePath(schemeFilter);146}147148return defaultWorkspacePath;149}150151async showSaveConfirm(fileNamesOrResources: (string | URI)[]): Promise<ConfirmResult> {152if (this.skipDialogs()) {153this.logService.trace('FileDialogService: refused to show save confirmation dialog in tests.');154155// no veto when we are in extension dev testing mode because we cannot assume we run interactive156return ConfirmResult.DONT_SAVE;157}158159return this.doShowSaveConfirm(fileNamesOrResources);160}161162private skipDialogs(): boolean {163if (this.environmentService.enableSmokeTestDriver) {164this.logService.warn('DialogService: Dialog requested during smoke test.');165}166// integration tests167return this.environmentService.isExtensionDevelopment && !!this.environmentService.extensionTestsLocationURI;168}169170private async doShowSaveConfirm(fileNamesOrResources: (string | URI)[]): Promise<ConfirmResult> {171if (fileNamesOrResources.length === 0) {172return ConfirmResult.DONT_SAVE;173}174175let message: string;176let detail = nls.localize('saveChangesDetail', "Your changes will be lost if you don't save them.");177if (fileNamesOrResources.length === 1) {178message = nls.localize('saveChangesMessage', "Do you want to save the changes you made to {0}?", typeof fileNamesOrResources[0] === 'string' ? fileNamesOrResources[0] : resources.basename(fileNamesOrResources[0]));179} else {180message = nls.localize('saveChangesMessages', "Do you want to save the changes to the following {0} files?", fileNamesOrResources.length);181detail = getFileNamesMessage(fileNamesOrResources) + '\n' + detail;182}183184const { result } = await this.dialogService.prompt<ConfirmResult>({185type: Severity.Warning,186message,187detail,188buttons: [189{190label: fileNamesOrResources.length > 1 ?191nls.localize({ key: 'saveAll', comment: ['&& denotes a mnemonic'] }, "&&Save All") :192nls.localize({ key: 'save', comment: ['&& denotes a mnemonic'] }, "&&Save"),193run: () => ConfirmResult.SAVE194},195{196label: nls.localize({ key: 'dontSave', comment: ['&& denotes a mnemonic'] }, "Do&&n't Save"),197run: () => ConfirmResult.DONT_SAVE198}199],200cancelButton: {201run: () => ConfirmResult.CANCEL202}203});204205return result;206}207208protected addFileSchemaIfNeeded(schema: string, _isFolder?: boolean): string[] {209return schema === Schemas.untitled ? [Schemas.file] : (schema !== Schemas.file ? [schema, Schemas.file] : [schema]);210}211212protected async pickFileFolderAndOpenSimplified(schema: string, options: IPickAndOpenOptions, preferNewWindow: boolean): Promise<void> {213const title = nls.localize('openFileOrFolder.title', 'Open File or Folder');214const availableFileSystems = this.addFileSchemaIfNeeded(schema);215216const uri = await this.pickResource({ canSelectFiles: true, canSelectFolders: true, canSelectMany: false, defaultUri: options.defaultUri, title, availableFileSystems });217218if (uri) {219const stat = await this.fileService.stat(uri);220221const toOpen: IWindowOpenable = stat.isDirectory ? { folderUri: uri } : { fileUri: uri };222if (!isWorkspaceToOpen(toOpen) && isFileToOpen(toOpen)) {223this.addFileToRecentlyOpened(toOpen.fileUri);224}225226if (stat.isDirectory || options.forceNewWindow || preferNewWindow) {227await this.hostService.openWindow([toOpen], { forceNewWindow: options.forceNewWindow, remoteAuthority: options.remoteAuthority });228} else {229await this.editorService.openEditors([{ resource: uri, options: { source: EditorOpenSource.USER, pinned: true } }], undefined, { validateTrust: true });230}231}232}233234protected async pickFileAndOpenSimplified(schema: string, options: IPickAndOpenOptions, preferNewWindow: boolean): Promise<void> {235const title = nls.localize('openFile.title', 'Open File');236const availableFileSystems = this.addFileSchemaIfNeeded(schema);237238const uri = await this.pickResource({ canSelectFiles: true, canSelectFolders: false, canSelectMany: false, defaultUri: options.defaultUri, title, availableFileSystems });239if (uri) {240this.addFileToRecentlyOpened(uri);241242if (options.forceNewWindow || preferNewWindow) {243await this.hostService.openWindow([{ fileUri: uri }], { forceNewWindow: options.forceNewWindow, remoteAuthority: options.remoteAuthority });244} else {245await this.editorService.openEditors([{ resource: uri, options: { source: EditorOpenSource.USER, pinned: true } }], undefined, { validateTrust: true });246}247}248}249250protected addFileToRecentlyOpened(uri: URI): void {251this.workspacesService.addRecentlyOpened([{ fileUri: uri, label: this.labelService.getUriLabel(uri, { appendWorkspaceSuffix: true }) }]);252}253254protected async pickFolderAndOpenSimplified(schema: string, options: IPickAndOpenOptions): Promise<void> {255const title = nls.localize('openFolder.title', 'Open Folder');256const availableFileSystems = this.addFileSchemaIfNeeded(schema, true);257258const uri = await this.pickResource({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false, defaultUri: options.defaultUri, title, availableFileSystems });259if (uri) {260return this.hostService.openWindow([{ folderUri: uri }], { forceNewWindow: options.forceNewWindow, remoteAuthority: options.remoteAuthority });261}262}263264protected async pickWorkspaceAndOpenSimplified(schema: string, options: IPickAndOpenOptions): Promise<void> {265const title = nls.localize('openWorkspace.title', 'Open Workspace from File');266const filters: FileFilter[] = [{ name: nls.localize('filterName.workspace', 'Workspace'), extensions: [WORKSPACE_EXTENSION] }];267const availableFileSystems = this.addFileSchemaIfNeeded(schema, true);268269const uri = await this.pickResource({ canSelectFiles: true, canSelectFolders: false, canSelectMany: false, defaultUri: options.defaultUri, title, filters, availableFileSystems });270if (uri) {271return this.hostService.openWindow([{ workspaceUri: uri }], { forceNewWindow: options.forceNewWindow, remoteAuthority: options.remoteAuthority });272}273}274275protected async pickFileToSaveSimplified(schema: string, options: ISaveDialogOptions): Promise<URI | undefined> {276if (!options.availableFileSystems) {277options.availableFileSystems = this.addFileSchemaIfNeeded(schema);278}279280options.title = nls.localize('saveFileAs.title', 'Save As');281const uri = await this.saveRemoteResource(options);282283if (uri) {284this.addFileToRecentlyOpened(uri);285}286287return uri;288}289290protected async showSaveDialogSimplified(schema: string, options: ISaveDialogOptions): Promise<URI | undefined> {291if (!options.availableFileSystems) {292options.availableFileSystems = this.addFileSchemaIfNeeded(schema);293}294295return this.saveRemoteResource(options);296}297298protected async showOpenDialogSimplified(schema: string, options: IOpenDialogOptions): Promise<URI[] | undefined> {299if (!options.availableFileSystems) {300options.availableFileSystems = this.addFileSchemaIfNeeded(schema, options.canSelectFolders);301}302303const uri = await this.pickResource(options);304305return uri ? [uri] : undefined;306}307308protected getSimpleFileDialog(): ISimpleFileDialog {309return this.instantiationService.createInstance(SimpleFileDialog);310}311312private pickResource(options: IOpenDialogOptions): Promise<URI | undefined> {313return this.getSimpleFileDialog().showOpenDialog(options);314}315316private saveRemoteResource(options: ISaveDialogOptions): Promise<URI | undefined> {317return this.getSimpleFileDialog().showSaveDialog(options);318}319320private getSchemeFilterForWindow(defaultUriScheme?: string): string {321return defaultUriScheme ?? this.pathService.defaultUriScheme;322}323324private getAuthorityFilterForWindow(): string | undefined {325return this.environmentService.remoteAuthority;326}327328protected getFileSystemSchema(options: { availableFileSystems?: readonly string[]; defaultUri?: URI }): string {329return options.availableFileSystems?.[0] || this.getSchemeFilterForWindow(options.defaultUri?.scheme);330}331332abstract pickFileFolderAndOpen(options: IPickAndOpenOptions): Promise<void>;333abstract pickFileAndOpen(options: IPickAndOpenOptions): Promise<void>;334abstract pickFolderAndOpen(options: IPickAndOpenOptions): Promise<void>;335abstract pickWorkspaceAndOpen(options: IPickAndOpenOptions): Promise<void>;336protected getWorkspaceAvailableFileSystems(options: IPickAndOpenOptions): string[] {337if (options.availableFileSystems && (options.availableFileSystems.length > 0)) {338return options.availableFileSystems;339}340const availableFileSystems = [Schemas.file];341if (this.environmentService.remoteAuthority) {342availableFileSystems.unshift(Schemas.vscodeRemote);343}344return availableFileSystems;345}346abstract showSaveDialog(options: ISaveDialogOptions): Promise<URI | undefined>;347abstract showOpenDialog(options: IOpenDialogOptions): Promise<URI[] | undefined>;348349abstract pickFileToSave(defaultUri: URI, availableFileSystems?: string[]): Promise<URI | undefined>;350351protected getPickFileToSaveDialogOptions(defaultUri: URI, availableFileSystems?: string[]): ISaveDialogOptions {352const options: ISaveDialogOptions = {353defaultUri,354title: nls.localize('saveAsTitle', "Save As"),355availableFileSystems356};357358interface IFilter { name: string; extensions: string[] }359360// Build the file filter by using our known languages361const ext: string | undefined = defaultUri ? resources.extname(defaultUri) : undefined;362let matchingFilter: IFilter | undefined;363364const registeredLanguageNames = this.languageService.getSortedRegisteredLanguageNames();365const registeredLanguageFilters: IFilter[] = coalesce(registeredLanguageNames.map(({ languageName, languageId }) => {366const extensions = this.languageService.getExtensions(languageId);367if (!extensions.length) {368return null;369}370371const filter: IFilter = { name: languageName, extensions: distinct(extensions).slice(0, 10).map(e => trim(e, '.')) };372373// https://github.com/microsoft/vscode/issues/115860374const extOrPlaintext = ext || PLAINTEXT_EXTENSION;375if (!matchingFilter && extensions.includes(extOrPlaintext)) {376matchingFilter = filter;377378// The selected extension must be in the set of extensions that are in the filter list that is sent to the save dialog.379// If it isn't, add it manually. https://github.com/microsoft/vscode/issues/147657380const trimmedExt = trim(extOrPlaintext, '.');381if (!filter.extensions.includes(trimmedExt)) {382filter.extensions.unshift(trimmedExt);383}384385return null; // first matching filter will be added to the top386}387388return filter;389}));390391// We have no matching filter, e.g. because the language392// is unknown. We still add the extension to the list of393// filters though so that it can be picked394// (https://github.com/microsoft/vscode/issues/96283)395if (!matchingFilter && ext) {396matchingFilter = { name: trim(ext, '.').toUpperCase(), extensions: [trim(ext, '.')] };397}398399// Order of filters is400// - All Files (we MUST do this to fix macOS issue https://github.com/microsoft/vscode/issues/102713)401// - File Extension Match (if any)402// - All Languages403// - No Extension404options.filters = coalesce([405{ name: nls.localize('allFiles', "All Files"), extensions: ['*'] },406matchingFilter,407...registeredLanguageFilters,408{ name: nls.localize('noExt', "No Extension"), extensions: [''] }409]);410411return options;412}413}414415416