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