Path: blob/main/src/vs/workbench/services/dialogs/browser/simpleFileDialog.ts
5237 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as nls from '../../../../nls.js';6import * as resources from '../../../../base/common/resources.js';7import * as objects from '../../../../base/common/objects.js';8import { IFileService, IFileStat, FileKind, IFileStatWithPartialMetadata } from '../../../../platform/files/common/files.js';9import { IQuickInputService, IQuickPickItem, IQuickPick, ItemActivation } from '../../../../platform/quickinput/common/quickInput.js';10import { URI } from '../../../../base/common/uri.js';11import { isWindows, OperatingSystem } from '../../../../base/common/platform.js';12import { ISaveDialogOptions, IOpenDialogOptions, IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js';13import { ILabelService } from '../../../../platform/label/common/label.js';14import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';15import { INotificationService } from '../../../../platform/notification/common/notification.js';16import { IModelService } from '../../../../editor/common/services/model.js';17import { ILanguageService } from '../../../../editor/common/languages/language.js';18import { getIconClasses } from '../../../../editor/common/services/getIconClasses.js';19import { Schemas } from '../../../../base/common/network.js';20import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js';21import { IRemoteAgentService } from '../../remote/common/remoteAgentService.js';22import { IContextKeyService, IContextKey, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';23import { equalsIgnoreCase, format, startsWithIgnoreCase } from '../../../../base/common/strings.js';24import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';25import { IRemoteAgentEnvironment } from '../../../../platform/remote/common/remoteAgentEnvironment.js';26import { isValidBasename } from '../../../../base/common/extpath.js';27import { Emitter } from '../../../../base/common/event.js';28import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';29import { createCancelablePromise, CancelablePromise } from '../../../../base/common/async.js';30import { CancellationToken } from '../../../../base/common/cancellation.js';31import { ICommandHandler } from '../../../../platform/commands/common/commands.js';32import { IEditorService } from '../../editor/common/editorService.js';33import { normalizeDriveLetter } from '../../../../base/common/labels.js';34import { SaveReason } from '../../../common/editor.js';35import { IPathService } from '../../path/common/pathService.js';36import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js';37import { getActiveDocument } from '../../../../base/browser/dom.js';38import { Codicon } from '../../../../base/common/codicons.js';39import { ThemeIcon } from '../../../../base/common/themables.js';40import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';4142export namespace OpenLocalFileCommand {43export const ID = 'workbench.action.files.openLocalFile';44export const LABEL = nls.localize('openLocalFile', "Open Local File...");45export function handler(): ICommandHandler {46return accessor => {47const dialogService = accessor.get(IFileDialogService);48return dialogService.pickFileAndOpen({ forceNewWindow: false, availableFileSystems: [Schemas.file] });49};50}51}5253export namespace SaveLocalFileCommand {54export const ID = 'workbench.action.files.saveLocalFile';55export const LABEL = nls.localize('saveLocalFile', "Save Local File...");56export function handler(): ICommandHandler {57return accessor => {58const editorService = accessor.get(IEditorService);59const activeEditorPane = editorService.activeEditorPane;60if (activeEditorPane) {61return editorService.save({ groupId: activeEditorPane.group.id, editor: activeEditorPane.input }, { saveAs: true, availableFileSystems: [Schemas.file], reason: SaveReason.EXPLICIT });62}6364return Promise.resolve(undefined);65};66}67}6869export namespace OpenLocalFolderCommand {70export const ID = 'workbench.action.files.openLocalFolder';71export const LABEL = nls.localize('openLocalFolder', "Open Local Folder...");72export function handler(): ICommandHandler {73return accessor => {74const dialogService = accessor.get(IFileDialogService);75return dialogService.pickFolderAndOpen({ forceNewWindow: false, availableFileSystems: [Schemas.file] });76};77}78}7980export namespace OpenLocalFileFolderCommand {81export const ID = 'workbench.action.files.openLocalFileFolder';82export const LABEL = nls.localize('openLocalFileFolder', "Open Local...");83export function handler(): ICommandHandler {84return accessor => {85const dialogService = accessor.get(IFileDialogService);86return dialogService.pickFileFolderAndOpen({ forceNewWindow: false, availableFileSystems: [Schemas.file] });87};88}89}9091interface FileQuickPickItem extends IQuickPickItem {92uri: URI;93isFolder: boolean;94}9596enum UpdateResult {97Updated,98UpdatedWithTrailing,99Updating,100NotUpdated,101InvalidPath102}103104export const RemoteFileDialogContext = new RawContextKey<boolean>('remoteFileDialogVisible', false);105106export interface ISimpleFileDialog extends IDisposable {107showOpenDialog(options: IOpenDialogOptions): Promise<URI | undefined>;108showSaveDialog(options: ISaveDialogOptions): Promise<URI | undefined>;109}110111export class SimpleFileDialog extends Disposable implements ISimpleFileDialog {112private options!: IOpenDialogOptions;113private currentFolder!: URI;114private filePickBox!: IQuickPick<FileQuickPickItem>;115private hidden: boolean = false;116private allowFileSelection: boolean = true;117private allowFolderSelection: boolean = false;118private remoteAuthority: string | undefined;119private requiresTrailing: boolean = false;120private trailing: string | undefined;121protected scheme: string;122private contextKey: IContextKey<boolean>;123private userEnteredPathSegment: string = '';124private autoCompletePathSegment: string = '';125private activeItem: FileQuickPickItem | undefined;126private userHome!: URI;127private trueHome!: URI;128private isWindows: boolean = false;129private badPath: string | undefined;130private remoteAgentEnvironment: IRemoteAgentEnvironment | null | undefined;131private separator: string = '/';132private readonly onBusyChangeEmitter = this._register(new Emitter<boolean>());133private updatingPromise: CancelablePromise<boolean> | undefined;134135private _showDotFiles: boolean = true;136137constructor(138@IFileService private readonly fileService: IFileService,139@IQuickInputService private readonly quickInputService: IQuickInputService,140@ILabelService private readonly labelService: ILabelService,141@IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService,142@INotificationService private readonly notificationService: INotificationService,143@IFileDialogService private readonly fileDialogService: IFileDialogService,144@IModelService private readonly modelService: IModelService,145@ILanguageService private readonly languageService: ILanguageService,146@IWorkbenchEnvironmentService protected readonly environmentService: IWorkbenchEnvironmentService,147@IRemoteAgentService private readonly remoteAgentService: IRemoteAgentService,148@IPathService protected readonly pathService: IPathService,149@IKeybindingService private readonly keybindingService: IKeybindingService,150@IContextKeyService contextKeyService: IContextKeyService,151@IAccessibilityService private readonly accessibilityService: IAccessibilityService,152@IStorageService private readonly storageService: IStorageService153) {154super();155this.remoteAuthority = this.environmentService.remoteAuthority;156this.contextKey = RemoteFileDialogContext.bindTo(contextKeyService);157this.scheme = this.pathService.defaultUriScheme;158159this.getShowDotFiles();160const disposableStore = this._register(new DisposableStore());161disposableStore.add(this.storageService.onDidChangeValue(StorageScope.WORKSPACE, 'remoteFileDialog.showDotFiles', disposableStore)(async _ => {162this.getShowDotFiles();163this.setButtons();164const startingValue = this.filePickBox.value;165const folderValue = this.pathFromUri(this.currentFolder, true);166this.filePickBox.value = folderValue;167await this.tryUpdateItems(folderValue, this.currentFolder, true);168this.filePickBox.value = startingValue;169}));170}171172private setShowDotFiles(showDotFiles: boolean) {173this.storageService.store('remoteFileDialog.showDotFiles', showDotFiles, StorageScope.WORKSPACE, StorageTarget.USER);174}175176private getShowDotFiles() {177this._showDotFiles = this.storageService.getBoolean('remoteFileDialog.showDotFiles', StorageScope.WORKSPACE, true);178}179180set busy(busy: boolean) {181if (this.filePickBox.busy !== busy) {182this.filePickBox.busy = busy;183this.onBusyChangeEmitter.fire(busy);184}185}186187get busy(): boolean {188return this.filePickBox.busy;189}190191public async showOpenDialog(options: IOpenDialogOptions = {}): Promise<URI | undefined> {192this.scheme = this.getScheme(options.availableFileSystems, options.defaultUri);193this.userHome = await this.getUserHome();194this.trueHome = await this.getUserHome(true);195const newOptions = this.getOptions(options);196if (!newOptions) {197return Promise.resolve(undefined);198}199this.options = newOptions;200return this.pickResource();201}202203public async showSaveDialog(options: ISaveDialogOptions): Promise<URI | undefined> {204this.scheme = this.getScheme(options.availableFileSystems, options.defaultUri);205this.userHome = await this.getUserHome();206this.trueHome = await this.getUserHome(true);207this.requiresTrailing = true;208const newOptions = this.getOptions(options, true);209if (!newOptions) {210return Promise.resolve(undefined);211}212this.options = newOptions;213this.options.canSelectFolders = true;214this.options.canSelectFiles = true;215216return new Promise<URI | undefined>((resolve) => {217this.pickResource(true).then(folderUri => {218resolve(folderUri);219});220});221}222223private getOptions(options: ISaveDialogOptions | IOpenDialogOptions, isSave: boolean = false): IOpenDialogOptions | undefined {224let defaultUri: URI | undefined = undefined;225let filename: string | undefined = undefined;226if (options.defaultUri) {227defaultUri = (this.scheme === options.defaultUri.scheme) ? options.defaultUri : undefined;228filename = isSave ? resources.basename(options.defaultUri) : undefined;229}230if (!defaultUri) {231defaultUri = this.userHome;232if (filename) {233defaultUri = resources.joinPath(defaultUri, filename);234}235}236if ((this.scheme !== Schemas.file) && !this.fileService.hasProvider(defaultUri)) {237this.notificationService.info(nls.localize('remoteFileDialog.notConnectedToRemote', 'File system provider for {0} is not available.', defaultUri.toString()));238return undefined;239}240const newOptions: IOpenDialogOptions = objects.deepClone(options);241newOptions.defaultUri = defaultUri;242return newOptions;243}244245private remoteUriFrom(path: string, hintUri?: URI): URI {246if (!path.startsWith('\\\\')) {247path = path.replace(/\\/g, '/');248}249const uri: URI = this.scheme === Schemas.file ? URI.file(path) : URI.from({ scheme: this.scheme, path, query: hintUri?.query, fragment: hintUri?.fragment });250// If the default scheme is file, then we don't care about the remote authority or the hint authority251const authority = (uri.scheme === Schemas.file) ? undefined : (this.remoteAuthority ?? hintUri?.authority);252return resources.toLocalResource(uri, authority,253// If there is a remote authority, then we should use the system's default URI as the local scheme.254// If there is *no* remote authority, then we should use the default scheme for this dialog as that is already local.255authority ? this.pathService.defaultUriScheme : uri.scheme);256}257258private getScheme(available: readonly string[] | undefined, defaultUri: URI | undefined): string {259if (available && available.length > 0) {260if (defaultUri && (available.indexOf(defaultUri.scheme) >= 0)) {261return defaultUri.scheme;262}263return available[0];264} else if (defaultUri) {265return defaultUri.scheme;266}267return Schemas.file;268}269270private async getRemoteAgentEnvironment(): Promise<IRemoteAgentEnvironment | null> {271if (this.remoteAgentEnvironment === undefined) {272this.remoteAgentEnvironment = await this.remoteAgentService.getEnvironment();273}274return this.remoteAgentEnvironment;275}276277protected getUserHome(trueHome = false): Promise<URI> {278return trueHome279? this.pathService.userHome({ preferLocal: this.scheme === Schemas.file })280: this.fileDialogService.preferredHome(this.scheme);281}282283private async pickResource(isSave: boolean = false): Promise<URI | undefined> {284this.allowFolderSelection = !!this.options.canSelectFolders;285this.allowFileSelection = !!this.options.canSelectFiles;286this.separator = this.labelService.getSeparator(this.scheme, this.remoteAuthority);287this.hidden = false;288this.isWindows = await this.checkIsWindowsOS();289let homedir: URI = this.options.defaultUri ? this.options.defaultUri : this.workspaceContextService.getWorkspace().folders[0].uri;290let stat: IFileStatWithPartialMetadata | undefined;291const ext: string = resources.extname(homedir);292if (this.options.defaultUri) {293try {294stat = await this.fileService.stat(this.options.defaultUri);295} catch (e) {296// The file or folder doesn't exist297}298if (!stat || !stat.isDirectory) {299homedir = resources.dirname(this.options.defaultUri);300this.trailing = resources.basename(this.options.defaultUri);301}302}303304return new Promise<URI | undefined>((resolve) => {305this.filePickBox = this._register(this.quickInputService.createQuickPick<FileQuickPickItem>());306this.busy = true;307this.filePickBox.matchOnLabel = false;308this.filePickBox.sortByLabel = false;309this.filePickBox.ignoreFocusOut = true;310this.filePickBox.placeholder = nls.localize('remoteFileDialog.placeholder', "Folder path");311this.filePickBox.ok = true;312this.filePickBox.okLabel = typeof this.options.openLabel === 'string' ? this.options.openLabel : this.options.openLabel?.withoutMnemonic;313if ((this.scheme !== Schemas.file) && this.options && this.options.availableFileSystems && (this.options.availableFileSystems.length > 1) && (this.options.availableFileSystems.indexOf(Schemas.file) > -1)) {314this.filePickBox.customButton = true;315this.filePickBox.customLabel = nls.localize('remoteFileDialog.local', 'Show Local');316this.filePickBox.customButtonSecondary = true;317let action;318if (isSave) {319action = SaveLocalFileCommand;320} else {321action = this.allowFileSelection ? (this.allowFolderSelection ? OpenLocalFileFolderCommand : OpenLocalFileCommand) : OpenLocalFolderCommand;322}323const keybinding = this.keybindingService.lookupKeybinding(action.ID);324if (keybinding) {325const label = keybinding.getLabel();326if (label) {327this.filePickBox.customHover = format('{0} ({1})', action.LABEL, label);328}329}330}331332this.setButtons();333this._register(this.filePickBox.onDidTriggerButton(e => {334this.setShowDotFiles(!this._showDotFiles);335}));336337let isResolving: number = 0;338let isAcceptHandled = false;339this.currentFolder = resources.dirname(homedir);340this.userEnteredPathSegment = '';341this.autoCompletePathSegment = '';342343this.filePickBox.title = this.options.title;344this.filePickBox.value = this.pathFromUri(this.currentFolder, true);345this.filePickBox.valueSelection = [this.filePickBox.value.length, this.filePickBox.value.length];346347const doResolve = (uri: URI | undefined) => {348if (uri) {349uri = resources.addTrailingPathSeparator(uri, this.separator); // Ensures that c: is c:/ since this comes from user input and can be incorrect.350// To be consistent, we should never have a trailing path separator on directories (or anything else). Will not remove from c:/.351uri = resources.removeTrailingPathSeparator(uri);352}353resolve(uri);354this.contextKey.set(false);355this.dispose();356};357358this._register(this.filePickBox.onDidCustom(() => {359if (isAcceptHandled || this.busy) {360return;361}362363isAcceptHandled = true;364isResolving++;365if (this.options.availableFileSystems && (this.options.availableFileSystems.length > 1)) {366this.options.availableFileSystems = this.options.availableFileSystems.slice(1);367}368this.filePickBox.hide();369if (isSave) {370return this.fileDialogService.showSaveDialog(this.options).then(result => {371doResolve(result);372});373} else {374return this.fileDialogService.showOpenDialog(this.options).then(result => {375doResolve(result ? result[0] : undefined);376});377}378}));379380const busyDisposable = this._register(new MutableDisposable());381const handleAccept = () => {382if (this.busy) {383// Save the accept until the file picker is not busy.384busyDisposable.value = this.onBusyChangeEmitter.event((busy: boolean) => {385if (!busy) {386handleAccept();387}388});389return;390} else if (isAcceptHandled) {391return;392}393394isAcceptHandled = true;395isResolving++;396this.onDidAccept().then(resolveValue => {397if (resolveValue) {398this.filePickBox.hide();399doResolve(resolveValue);400} else if (this.hidden) {401doResolve(undefined);402} else {403isResolving--;404isAcceptHandled = false;405}406});407};408409this._register(this.filePickBox.onDidAccept(_ => {410handleAccept();411}));412413this._register(this.filePickBox.onDidChangeActive(i => {414isAcceptHandled = false;415// update input box to match the first selected item416if ((i.length === 1) && this.isSelectionChangeFromUser()) {417this.filePickBox.validationMessage = undefined;418const userPath = this.constructFullUserPath();419if (!equalsIgnoreCase(this.filePickBox.value.substring(0, userPath.length), userPath)) {420this.filePickBox.valueSelection = [0, this.filePickBox.value.length];421this.insertText(userPath, userPath);422}423this.setAutoComplete(userPath, this.userEnteredPathSegment, i[0], true);424}425}));426427this._register(this.filePickBox.onDidChangeValue(async value => {428return this.handleValueChange(value);429}));430this._register(this.filePickBox.onDidHide(() => {431this.hidden = true;432if (isResolving === 0) {433doResolve(undefined);434}435}));436437this.filePickBox.show();438this.contextKey.set(true);439this.updateItems(homedir, true, this.trailing).then(() => {440if (this.trailing) {441this.filePickBox.valueSelection = [this.filePickBox.value.length - this.trailing.length, this.filePickBox.value.length - ext.length];442} else {443this.filePickBox.valueSelection = [this.filePickBox.value.length, this.filePickBox.value.length];444}445this.busy = false;446});447});448}449450public override dispose(): void {451super.dispose();452}453454private async handleValueChange(value: string) {455try {456// onDidChangeValue can also be triggered by the auto complete, so if it looks like the auto complete, don't do anything457if (this.isValueChangeFromUser()) {458// If the user has just entered more bad path, don't change anything459if (!equalsIgnoreCase(value, this.constructFullUserPath()) && (!this.isBadSubpath(value) || this.canTildaEscapeHatch(value))) {460this.filePickBox.validationMessage = undefined;461const filePickBoxUri = this.filePickBoxValue();462let updated: UpdateResult = UpdateResult.NotUpdated;463if (!resources.extUriIgnorePathCase.isEqual(this.currentFolder, filePickBoxUri)) {464updated = await this.tryUpdateItems(value, filePickBoxUri);465}466if ((updated === UpdateResult.NotUpdated) || (updated === UpdateResult.UpdatedWithTrailing)) {467this.setActiveItems(value);468}469} else {470this.filePickBox.activeItems = [];471this.userEnteredPathSegment = '';472}473}474} catch {475// Since any text can be entered in the input box, there is potential for error causing input. If this happens, do nothing.476}477}478479private setButtons() {480this.filePickBox.buttons = [{481iconClass: this._showDotFiles ? ThemeIcon.asClassName(Codicon.eye) : ThemeIcon.asClassName(Codicon.eyeClosed),482tooltip: this._showDotFiles ? nls.localize('remoteFileDialog.hideDotFiles', "Hide dot files") : nls.localize('remoteFileDialog.showDotFiles', "Show dot files"),483alwaysVisible: true484}];485}486487private isBadSubpath(value: string) {488return this.badPath && (value.length > this.badPath.length) && equalsIgnoreCase(value.substring(0, this.badPath.length), this.badPath);489}490491private isValueChangeFromUser(): boolean {492if (equalsIgnoreCase(this.filePickBox.value, this.pathAppend(this.currentFolder, this.userEnteredPathSegment + this.autoCompletePathSegment))) {493return false;494}495return true;496}497498private isSelectionChangeFromUser(): boolean {499if (this.activeItem === (this.filePickBox.activeItems ? this.filePickBox.activeItems[0] : undefined)) {500return false;501}502return true;503}504505private constructFullUserPath(): string {506const currentFolderPath = this.pathFromUri(this.currentFolder);507if (equalsIgnoreCase(this.filePickBox.value.substr(0, this.userEnteredPathSegment.length), this.userEnteredPathSegment)) {508if (equalsIgnoreCase(this.filePickBox.value.substr(0, currentFolderPath.length), currentFolderPath)) {509return currentFolderPath;510} else {511return this.userEnteredPathSegment;512}513} else {514return this.pathAppend(this.currentFolder, this.userEnteredPathSegment);515}516}517518private filePickBoxValue(): URI {519// The file pick box can't render everything, so we use the current folder to create the uri so that it is an existing path.520const directUri = this.remoteUriFrom(this.filePickBox.value.trimRight(), this.currentFolder);521const currentPath = this.pathFromUri(this.currentFolder);522if (equalsIgnoreCase(this.filePickBox.value, currentPath)) {523return this.currentFolder;524}525const currentDisplayUri = this.remoteUriFrom(currentPath, this.currentFolder);526const relativePath = resources.relativePath(currentDisplayUri, directUri);527const isSameRoot = (this.filePickBox.value.length > 1 && currentPath.length > 1) ? equalsIgnoreCase(this.filePickBox.value.substr(0, 2), currentPath.substr(0, 2)) : false;528if (relativePath && isSameRoot) {529let path = resources.joinPath(this.currentFolder, relativePath);530const directBasename = resources.basename(directUri);531if ((directBasename === '.') || (directBasename === '..')) {532path = this.remoteUriFrom(this.pathAppend(path, directBasename), this.currentFolder);533}534return resources.hasTrailingPathSeparator(directUri) ? resources.addTrailingPathSeparator(path) : path;535} else {536return directUri;537}538}539540private async onDidAccept(): Promise<URI | undefined> {541this.busy = true;542if (!this.updatingPromise && this.filePickBox.activeItems.length === 1) {543const item = this.filePickBox.selectedItems[0];544if (item.isFolder) {545if (this.trailing) {546await this.updateItems(item.uri, true, this.trailing);547} else {548// When possible, cause the update to happen by modifying the input box.549// This allows all input box updates to happen first, and uses the same code path as the user typing.550const newPath = this.pathFromUri(item.uri);551if (startsWithIgnoreCase(newPath, this.filePickBox.value) && (equalsIgnoreCase(item.label, resources.basename(item.uri)))) {552this.filePickBox.valueSelection = [this.pathFromUri(this.currentFolder).length, this.filePickBox.value.length];553this.insertText(newPath, this.basenameWithTrailingSlash(item.uri));554} else if ((item.label === '..') && startsWithIgnoreCase(this.filePickBox.value, newPath)) {555this.filePickBox.valueSelection = [newPath.length, this.filePickBox.value.length];556this.insertText(newPath, '');557} else {558await this.updateItems(item.uri, true);559}560}561this.filePickBox.busy = false;562return;563}564} else if (!this.updatingPromise) {565// If the items have updated, don't try to resolve566if ((await this.tryUpdateItems(this.filePickBox.value, this.filePickBoxValue())) !== UpdateResult.NotUpdated) {567this.filePickBox.busy = false;568return;569}570}571572let resolveValue: URI | undefined;573// Find resolve value574if (this.filePickBox.activeItems.length === 0) {575resolveValue = this.filePickBoxValue();576} else if (this.filePickBox.activeItems.length === 1) {577resolveValue = this.filePickBox.selectedItems[0].uri;578}579if (resolveValue) {580resolveValue = this.addPostfix(resolveValue);581}582if (await this.validate(resolveValue)) {583this.busy = false;584return resolveValue;585}586this.busy = false;587return undefined;588}589590private root(value: URI) {591let lastDir = value;592let dir = resources.dirname(value);593while (!resources.isEqual(lastDir, dir)) {594lastDir = dir;595dir = resources.dirname(dir);596}597return dir;598}599600private canTildaEscapeHatch(value: string): boolean {601return !!(value.endsWith('~') && this.isBadSubpath(value));602}603604private tildaReplace(value: string): URI {605const home = this.trueHome;606if ((value.length > 0) && (value[0] === '~')) {607return resources.joinPath(home, value.substring(1));608} else if (this.canTildaEscapeHatch(value)) {609return home;610}611return this.remoteUriFrom(value);612}613614private tryAddTrailingSeparatorToDirectory(uri: URI, stat: IFileStatWithPartialMetadata): URI {615if (stat.isDirectory) {616// At this point we know it's a directory and can add the trailing path separator617if (!this.endsWithSlash(uri.path)) {618return resources.addTrailingPathSeparator(uri);619}620}621return uri;622}623624private async tryUpdateItems(value: string, valueUri: URI, reset: boolean = false): Promise<UpdateResult> {625if ((value.length > 0) && ((value[0] === '~') || this.canTildaEscapeHatch(value))) {626const newDir = this.tildaReplace(value);627return await this.updateItems(newDir, true) ? UpdateResult.UpdatedWithTrailing : UpdateResult.Updated;628} else if (value === '\\') {629valueUri = this.root(this.currentFolder);630value = this.pathFromUri(valueUri);631return await this.updateItems(valueUri, true) ? UpdateResult.UpdatedWithTrailing : UpdateResult.Updated;632} else {633const newFolderIsOldFolder = resources.extUriIgnorePathCase.isEqual(this.currentFolder, valueUri);634const newFolderIsSubFolder = resources.extUriIgnorePathCase.isEqual(this.currentFolder, resources.dirname(valueUri));635const newFolderIsParent = resources.extUriIgnorePathCase.isEqualOrParent(this.currentFolder, resources.dirname(valueUri));636const newFolderIsUnrelated = !newFolderIsParent && !newFolderIsSubFolder;637if ((!newFolderIsOldFolder && (this.endsWithSlash(value) || newFolderIsParent || newFolderIsUnrelated)) || reset) {638let stat: IFileStatWithPartialMetadata | undefined;639try {640stat = await this.fileService.stat(valueUri);641} catch (e) {642// do nothing643}644if (stat?.isDirectory && (resources.basename(valueUri) !== '.') && this.endsWithSlash(value)) {645valueUri = this.tryAddTrailingSeparatorToDirectory(valueUri, stat);646return await this.updateItems(valueUri) ? UpdateResult.UpdatedWithTrailing : UpdateResult.Updated;647} else if (this.endsWithSlash(value)) {648// The input box contains a path that doesn't exist on the system.649this.filePickBox.validationMessage = nls.localize('remoteFileDialog.badPath', 'The path does not exist. Use ~ to go to your home directory.');650// Save this bad path. It can take too long to a stat on every user entered character, but once a user enters a bad path they are likely651// to keep typing more bad path. We can compare against this bad path and see if the user entered path starts with it.652this.badPath = value;653return UpdateResult.InvalidPath;654} else {655let inputUriDirname = resources.dirname(valueUri);656const currentFolderWithoutSep = resources.removeTrailingPathSeparator(resources.addTrailingPathSeparator(this.currentFolder));657const inputUriDirnameWithoutSep = resources.removeTrailingPathSeparator(resources.addTrailingPathSeparator(inputUriDirname));658if (!resources.extUriIgnorePathCase.isEqual(currentFolderWithoutSep, inputUriDirnameWithoutSep)659&& (!/^[a-zA-Z]:$/.test(this.filePickBox.value)660|| !equalsIgnoreCase(this.pathFromUri(this.currentFolder).substring(0, this.filePickBox.value.length), this.filePickBox.value))) {661let statWithoutTrailing: IFileStatWithPartialMetadata | undefined;662try {663statWithoutTrailing = await this.fileService.stat(inputUriDirname);664} catch (e) {665// do nothing666}667if (statWithoutTrailing?.isDirectory) {668this.badPath = undefined;669inputUriDirname = this.tryAddTrailingSeparatorToDirectory(inputUriDirname, statWithoutTrailing);670return await this.updateItems(inputUriDirname, false, resources.basename(valueUri)) ? UpdateResult.UpdatedWithTrailing : UpdateResult.Updated;671}672}673}674}675}676this.badPath = undefined;677return UpdateResult.NotUpdated;678}679680private tryUpdateTrailing(value: URI) {681const ext = resources.extname(value);682if (this.trailing && ext) {683this.trailing = resources.basename(value);684}685}686687private setActiveItems(value: string) {688value = this.pathFromUri(this.tildaReplace(value));689const asUri = this.remoteUriFrom(value);690const inputBasename = resources.basename(asUri);691const userPath = this.constructFullUserPath();692// Make sure that the folder whose children we are currently viewing matches the path in the input693const pathsEqual = equalsIgnoreCase(userPath, value.substring(0, userPath.length)) ||694equalsIgnoreCase(value, userPath.substring(0, value.length));695if (pathsEqual) {696let hasMatch = false;697for (let i = 0; i < this.filePickBox.items.length; i++) {698const item = <FileQuickPickItem>this.filePickBox.items[i];699if (this.setAutoComplete(value, inputBasename, item)) {700hasMatch = true;701break;702}703}704if (!hasMatch) {705const userBasename = inputBasename.length >= 2 ? userPath.substring(userPath.length - inputBasename.length + 2) : '';706this.userEnteredPathSegment = (userBasename === inputBasename) ? inputBasename : '';707this.autoCompletePathSegment = '';708this.filePickBox.activeItems = [];709this.tryUpdateTrailing(asUri);710}711} else {712this.userEnteredPathSegment = inputBasename;713this.autoCompletePathSegment = '';714this.filePickBox.activeItems = [];715this.tryUpdateTrailing(asUri);716}717}718719private setAutoComplete(startingValue: string, startingBasename: string, quickPickItem: FileQuickPickItem, force: boolean = false): boolean {720if (this.busy) {721// We're in the middle of something else. Doing an auto complete now can result jumbled or incorrect autocompletes.722this.userEnteredPathSegment = startingBasename;723this.autoCompletePathSegment = '';724return false;725}726const itemBasename = quickPickItem.label;727// Either force the autocomplete, or the old value should be one smaller than the new value and match the new value.728if (itemBasename === '..') {729// Don't match on the up directory item ever.730this.userEnteredPathSegment = '';731this.autoCompletePathSegment = '';732this.activeItem = quickPickItem;733if (force) {734// clear any selected text735getActiveDocument().execCommand('insertText', false, '');736}737return false;738} else if (!force && (itemBasename.length >= startingBasename.length) && equalsIgnoreCase(itemBasename.substr(0, startingBasename.length), startingBasename)) {739this.userEnteredPathSegment = startingBasename;740this.activeItem = quickPickItem;741// Changing the active items will trigger the onDidActiveItemsChanged. Clear the autocomplete first, then set it after.742this.autoCompletePathSegment = '';743if (quickPickItem.isFolder || !this.trailing) {744this.filePickBox.activeItems = [quickPickItem];745} else {746this.filePickBox.activeItems = [];747}748return true;749} else if (force && (!equalsIgnoreCase(this.basenameWithTrailingSlash(quickPickItem.uri), (this.userEnteredPathSegment + this.autoCompletePathSegment)))) {750this.userEnteredPathSegment = '';751if (!this.accessibilityService.isScreenReaderOptimized()) {752this.autoCompletePathSegment = this.trimTrailingSlash(itemBasename);753}754this.activeItem = quickPickItem;755if (!this.accessibilityService.isScreenReaderOptimized()) {756this.filePickBox.valueSelection = [this.pathFromUri(this.currentFolder, true).length, this.filePickBox.value.length];757// use insert text to preserve undo buffer758this.insertText(this.pathAppend(this.currentFolder, this.autoCompletePathSegment), this.autoCompletePathSegment);759this.filePickBox.valueSelection = [this.filePickBox.value.length - this.autoCompletePathSegment.length, this.filePickBox.value.length];760}761return true;762} else {763this.userEnteredPathSegment = startingBasename;764this.autoCompletePathSegment = '';765return false;766}767}768769private insertText(wholeValue: string, insertText: string) {770if (this.filePickBox.inputHasFocus()) {771getActiveDocument().execCommand('insertText', false, insertText);772if (this.filePickBox.value !== wholeValue) {773this.filePickBox.value = wholeValue;774this.handleValueChange(wholeValue);775}776} else {777this.filePickBox.value = wholeValue;778this.handleValueChange(wholeValue);779}780}781782private addPostfix(uri: URI): URI {783let result = uri;784if (this.requiresTrailing && this.options.filters && this.options.filters.length > 0 && !resources.hasTrailingPathSeparator(uri)) {785// Make sure that the suffix is added. If the user deleted it, we automatically add it here786let hasExt: boolean = false;787const currentExt = resources.extname(uri).substr(1);788for (let i = 0; i < this.options.filters.length; i++) {789for (let j = 0; j < this.options.filters[i].extensions.length; j++) {790if ((this.options.filters[i].extensions[j] === '*') || (this.options.filters[i].extensions[j] === currentExt)) {791hasExt = true;792break;793}794}795if (hasExt) {796break;797}798}799if (!hasExt) {800result = resources.joinPath(resources.dirname(uri), resources.basename(uri) + '.' + this.options.filters[0].extensions[0]);801}802}803return result;804}805806private trimTrailingSlash(path: string): string {807return ((path.length > 1) && this.endsWithSlash(path)) ? path.substr(0, path.length - 1) : path;808}809810private yesNoPrompt(uri: URI, message: string): Promise<boolean> {811interface YesNoItem extends IQuickPickItem {812value: boolean;813}814const disposableStore = new DisposableStore();815const prompt = disposableStore.add(this.quickInputService.createQuickPick<YesNoItem>());816prompt.title = message;817prompt.ignoreFocusOut = true;818prompt.ok = true;819prompt.customButton = true;820prompt.customLabel = nls.localize('remoteFileDialog.cancel', 'Cancel');821prompt.customButtonSecondary = true;822prompt.value = this.pathFromUri(uri);823824let isResolving = false;825return new Promise<boolean>(resolve => {826disposableStore.add(prompt.onDidAccept(() => {827isResolving = true;828prompt.hide();829resolve(true);830}));831disposableStore.add(prompt.onDidHide(() => {832if (!isResolving) {833resolve(false);834}835this.filePickBox.show();836this.hidden = false;837disposableStore.dispose();838}));839disposableStore.add(prompt.onDidChangeValue(() => {840prompt.hide();841}));842disposableStore.add(prompt.onDidCustom(() => {843prompt.hide();844}));845prompt.show();846});847}848849private async validate(uri: URI | undefined): Promise<boolean> {850if (uri === undefined) {851this.filePickBox.validationMessage = nls.localize('remoteFileDialog.invalidPath', 'Please enter a valid path.');852return Promise.resolve(false);853}854855let stat: IFileStatWithPartialMetadata | undefined;856let statDirname: IFileStatWithPartialMetadata | undefined;857try {858statDirname = await this.fileService.stat(resources.dirname(uri));859stat = await this.fileService.stat(uri);860} catch (e) {861// do nothing862}863864if (this.requiresTrailing) { // save865if (stat?.isDirectory) {866// Can't do this867this.filePickBox.validationMessage = nls.localize('remoteFileDialog.validateFolder', 'The folder already exists. Please use a new file name.');868return Promise.resolve(false);869} else if (stat) {870// Replacing a file.871// Show a yes/no prompt872const message = nls.localize('remoteFileDialog.validateExisting', '{0} already exists. Are you sure you want to overwrite it?', resources.basename(uri));873return this.yesNoPrompt(uri, message);874} else if (!(isValidBasename(resources.basename(uri), this.isWindows))) {875// Filename not allowed876this.filePickBox.validationMessage = nls.localize('remoteFileDialog.validateBadFilename', 'Please enter a valid file name.');877return Promise.resolve(false);878} else if (!statDirname) {879// Folder to save in doesn't exist880const message = nls.localize('remoteFileDialog.validateCreateDirectory', 'The folder {0} does not exist. Would you like to create it?', resources.basename(resources.dirname(uri)));881return this.yesNoPrompt(uri, message);882} else if (!statDirname.isDirectory) {883this.filePickBox.validationMessage = nls.localize('remoteFileDialog.validateNonexistentDir', 'Please enter a path that exists.');884return Promise.resolve(false);885} else if (statDirname.readonly) {886this.filePickBox.validationMessage = nls.localize('remoteFileDialog.validateReadonlyFolder', 'This folder cannot be used as a save destination. Please choose another folder');887return Promise.resolve(false);888}889} else { // open890if (!stat) {891// File or folder doesn't exist892this.filePickBox.validationMessage = nls.localize('remoteFileDialog.validateNonexistentDir', 'Please enter a path that exists.');893return Promise.resolve(false);894} else if (uri.path === '/' && this.isWindows) {895this.filePickBox.validationMessage = nls.localize('remoteFileDialog.windowsDriveLetter', 'Please start the path with a drive letter.');896return Promise.resolve(false);897} else if (stat.isDirectory && !this.allowFolderSelection) {898// Folder selected when folder selection not permitted899this.filePickBox.validationMessage = nls.localize('remoteFileDialog.validateFileOnly', 'Please select a file.');900return Promise.resolve(false);901} else if (!stat.isDirectory && !this.allowFileSelection) {902// File selected when file selection not permitted903this.filePickBox.validationMessage = nls.localize('remoteFileDialog.validateFolderOnly', 'Please select a folder.');904return Promise.resolve(false);905}906}907return Promise.resolve(true);908}909910// Returns true if there is a file at the end of the URI.911private async updateItems(newFolder: URI, force: boolean = false, trailing?: string): Promise<boolean> {912this.busy = true;913this.autoCompletePathSegment = '';914const wasDotDot = trailing === '..';915trailing = wasDotDot ? undefined : trailing;916const isSave = !!trailing;917let result = false;918919const updatingPromise = createCancelablePromise(async token => {920let folderStat: IFileStat | undefined;921try {922folderStat = await this.fileService.resolve(newFolder);923if (!folderStat.isDirectory) {924trailing = resources.basename(newFolder);925newFolder = resources.dirname(newFolder);926folderStat = undefined;927result = true;928}929} catch (e) {930// The file/directory doesn't exist931}932const newValue = trailing ? this.pathAppend(newFolder, trailing) : this.pathFromUri(newFolder, true);933this.currentFolder = this.endsWithSlash(newFolder.path) ? newFolder : resources.addTrailingPathSeparator(newFolder, this.separator);934this.userEnteredPathSegment = trailing ? trailing : '';935936return this.createItems(folderStat, this.currentFolder, token).then(items => {937if (token.isCancellationRequested) {938this.busy = false;939return false;940}941942this.filePickBox.itemActivation = ItemActivation.NONE;943this.filePickBox.items = items;944945// the user might have continued typing while we were updating. Only update the input box if it doesn't match the directory.946if (!equalsIgnoreCase(this.filePickBox.value, newValue) && (force || wasDotDot)) {947this.filePickBox.valueSelection = [0, this.filePickBox.value.length];948this.insertText(newValue, newValue);949}950if (force && trailing && isSave) {951// Keep the cursor position in front of the save as name.952this.filePickBox.valueSelection = [this.filePickBox.value.length - trailing.length, this.filePickBox.value.length - trailing.length];953} else if (!trailing) {954// If there is trailing, we don't move the cursor. If there is no trailing, cursor goes at the end.955this.filePickBox.valueSelection = [this.filePickBox.value.length, this.filePickBox.value.length];956}957this.busy = false;958this.updatingPromise = undefined;959return result;960});961});962963if (this.updatingPromise !== undefined) {964this.updatingPromise.cancel();965}966this.updatingPromise = updatingPromise;967968return updatingPromise;969}970971private pathFromUri(uri: URI, endWithSeparator: boolean = false): string {972let result: string = normalizeDriveLetter(uri.fsPath, this.isWindows).replace(/\n/g, '');973if (this.separator === '/') {974result = result.replace(/\\/g, this.separator);975} else {976result = result.replace(/\//g, this.separator);977}978if (endWithSeparator && !this.endsWithSlash(result)) {979result = result + this.separator;980}981return result;982}983984private pathAppend(uri: URI, additional: string): string {985if ((additional === '..') || (additional === '.')) {986const basePath = this.pathFromUri(uri, true);987return basePath + additional;988} else {989return this.pathFromUri(resources.joinPath(uri, additional));990}991}992993private async checkIsWindowsOS(): Promise<boolean> {994let isWindowsOS = isWindows;995const env = await this.getRemoteAgentEnvironment();996if (env) {997isWindowsOS = env.os === OperatingSystem.Windows;998}999return isWindowsOS;1000}10011002private endsWithSlash(s: string) {1003return /[\/\\]$/.test(s);1004}10051006private basenameWithTrailingSlash(fullPath: URI): string {1007const child = this.pathFromUri(fullPath, true);1008const parent = this.pathFromUri(resources.dirname(fullPath), true);1009return child.substring(parent.length);1010}10111012private async createBackItem(currFolder: URI): Promise<FileQuickPickItem | undefined> {1013const fileRepresentationCurr = this.currentFolder.with({ scheme: Schemas.file, authority: '' });1014const fileRepresentationParent = resources.dirname(fileRepresentationCurr);1015if (!resources.isEqual(fileRepresentationCurr, fileRepresentationParent)) {1016const parentFolder = resources.dirname(currFolder);1017if (await this.fileService.exists(parentFolder)) {1018return { label: '..', uri: resources.addTrailingPathSeparator(parentFolder, this.separator), isFolder: true };1019}1020}1021return undefined;1022}10231024private async createItems(folder: IFileStat | undefined, currentFolder: URI, token: CancellationToken): Promise<FileQuickPickItem[]> {1025const result: FileQuickPickItem[] = [];10261027const backDir = await this.createBackItem(currentFolder);1028try {1029if (!folder) {1030folder = await this.fileService.resolve(currentFolder);1031}1032const filteredChildren = this._showDotFiles ? folder.children : folder.children?.filter(child => !child.name.startsWith('.'));1033const items = filteredChildren ? await Promise.all(filteredChildren.map(child => this.createItem(child, currentFolder, token))) : [];1034for (const item of items) {1035if (item) {1036result.push(item);1037}1038}1039} catch (e) {1040// ignore1041console.log(e);1042}1043if (token.isCancellationRequested) {1044return [];1045}1046const sorted = result.sort((i1, i2) => {1047if (i1.isFolder !== i2.isFolder) {1048return i1.isFolder ? -1 : 1;1049}1050const trimmed1 = this.endsWithSlash(i1.label) ? i1.label.substr(0, i1.label.length - 1) : i1.label;1051const trimmed2 = this.endsWithSlash(i2.label) ? i2.label.substr(0, i2.label.length - 1) : i2.label;1052return trimmed1.localeCompare(trimmed2);1053});10541055if (backDir) {1056sorted.unshift(backDir);1057}1058return sorted;1059}10601061private filterFile(file: URI): boolean {1062if (this.options.filters) {1063for (let i = 0; i < this.options.filters.length; i++) {1064for (let j = 0; j < this.options.filters[i].extensions.length; j++) {1065const testExt = this.options.filters[i].extensions[j];1066if ((testExt === '*') || (file.path.endsWith('.' + testExt))) {1067return true;1068}1069}1070}1071return false;1072}1073return true;1074}10751076private async createItem(stat: IFileStat, parent: URI, token: CancellationToken): Promise<FileQuickPickItem | undefined> {1077if (token.isCancellationRequested) {1078return undefined;1079}1080let fullPath = resources.joinPath(parent, stat.name);1081if (stat.isDirectory) {1082const filename = resources.basename(fullPath);1083fullPath = resources.addTrailingPathSeparator(fullPath, this.separator);1084return { label: filename, uri: fullPath, isFolder: true, iconClasses: getIconClasses(this.modelService, this.languageService, fullPath || undefined, FileKind.FOLDER) };1085} else if (!stat.isDirectory && this.allowFileSelection && this.filterFile(fullPath)) {1086return { label: stat.name, uri: fullPath, isFolder: false, iconClasses: getIconClasses(this.modelService, this.languageService, fullPath || undefined) };1087}1088return undefined;1089}1090}109110921093