Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/files/browser/explorerService.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 { Event } from '../../../../base/common/event.js';
7
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
8
import { DisposableStore } from '../../../../base/common/lifecycle.js';
9
import { IFilesConfiguration, ISortOrderConfiguration, SortOrder, LexicographicOptions } from '../common/files.js';
10
import { ExplorerItem, ExplorerModel } from '../common/explorerModel.js';
11
import { URI } from '../../../../base/common/uri.js';
12
import { FileOperationEvent, FileOperation, IFileService, FileChangesEvent, FileChangeType, IResolveFileOptions } from '../../../../platform/files/common/files.js';
13
import { dirname, basename } from '../../../../base/common/resources.js';
14
import { IConfigurationService, IConfigurationChangeEvent } from '../../../../platform/configuration/common/configuration.js';
15
import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js';
16
import { IEditorService } from '../../../services/editor/common/editorService.js';
17
import { IEditableData } from '../../../common/views.js';
18
import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js';
19
import { IBulkEditService, ResourceFileEdit } from '../../../../editor/browser/services/bulkEditService.js';
20
import { UndoRedoSource } from '../../../../platform/undoRedo/common/undoRedo.js';
21
import { IExplorerView, IExplorerService } from './files.js';
22
import { IProgressService, ProgressLocation, IProgressCompositeOptions, IProgressOptions } from '../../../../platform/progress/common/progress.js';
23
import { CancellationTokenSource } from '../../../../base/common/cancellation.js';
24
import { RunOnceScheduler } from '../../../../base/common/async.js';
25
import { IHostService } from '../../../services/host/browser/host.js';
26
import { IExpression } from '../../../../base/common/glob.js';
27
import { ResourceGlobMatcher } from '../../../common/resources.js';
28
import { IFilesConfigurationService } from '../../../services/filesConfiguration/common/filesConfigurationService.js';
29
30
export const UNDO_REDO_SOURCE = new UndoRedoSource();
31
32
export class ExplorerService implements IExplorerService {
33
declare readonly _serviceBrand: undefined;
34
35
private static readonly EXPLORER_FILE_CHANGES_REACT_DELAY = 500; // delay in ms to react to file changes to give our internal events a chance to react first
36
37
private readonly disposables = new DisposableStore();
38
private editable: { stat: ExplorerItem; data: IEditableData } | undefined;
39
private config: IFilesConfiguration['explorer'];
40
private cutItems: ExplorerItem[] | undefined;
41
private view: IExplorerView | undefined;
42
private model: ExplorerModel;
43
private onFileChangesScheduler: RunOnceScheduler;
44
private fileChangeEvents: FileChangesEvent[] = [];
45
private revealExcludeMatcher: ResourceGlobMatcher;
46
47
constructor(
48
@IFileService private fileService: IFileService,
49
@IConfigurationService private configurationService: IConfigurationService,
50
@IWorkspaceContextService private contextService: IWorkspaceContextService,
51
@IClipboardService private clipboardService: IClipboardService,
52
@IEditorService private editorService: IEditorService,
53
@IUriIdentityService private readonly uriIdentityService: IUriIdentityService,
54
@IBulkEditService private readonly bulkEditService: IBulkEditService,
55
@IProgressService private readonly progressService: IProgressService,
56
@IHostService hostService: IHostService,
57
@IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService
58
) {
59
this.config = this.configurationService.getValue('explorer');
60
61
this.model = new ExplorerModel(this.contextService, this.uriIdentityService, this.fileService, this.configurationService, this.filesConfigurationService);
62
this.disposables.add(this.model);
63
this.disposables.add(this.fileService.onDidRunOperation(e => this.onDidRunOperation(e)));
64
65
this.onFileChangesScheduler = new RunOnceScheduler(async () => {
66
const events = this.fileChangeEvents;
67
this.fileChangeEvents = [];
68
69
// Filter to the ones we care
70
const types = [FileChangeType.DELETED];
71
if (this.config.sortOrder === SortOrder.Modified) {
72
types.push(FileChangeType.UPDATED);
73
}
74
75
let shouldRefresh = false;
76
// For DELETED and UPDATED events go through the explorer model and check if any of the items got affected
77
this.roots.forEach(r => {
78
if (this.view && !shouldRefresh) {
79
shouldRefresh = doesFileEventAffect(r, this.view, events, types);
80
}
81
});
82
// For ADDED events we need to go through all the events and check if the explorer is already aware of some of them
83
// Or if they affect not yet resolved parts of the explorer. If that is the case we will not refresh.
84
events.forEach(e => {
85
if (!shouldRefresh) {
86
for (const resource of e.rawAdded) {
87
const parent = this.model.findClosest(dirname(resource));
88
// Parent of the added resource is resolved and the explorer model is not aware of the added resource - we need to refresh
89
if (parent && !parent.getChild(basename(resource))) {
90
shouldRefresh = true;
91
break;
92
}
93
}
94
}
95
});
96
97
if (shouldRefresh) {
98
await this.refresh(false);
99
}
100
101
}, ExplorerService.EXPLORER_FILE_CHANGES_REACT_DELAY);
102
103
this.disposables.add(this.fileService.onDidFilesChange(e => {
104
this.fileChangeEvents.push(e);
105
// Don't mess with the file tree while in the process of editing. #112293
106
if (this.editable) {
107
return;
108
}
109
if (!this.onFileChangesScheduler.isScheduled()) {
110
this.onFileChangesScheduler.schedule();
111
}
112
}));
113
this.disposables.add(this.configurationService.onDidChangeConfiguration(e => this.onConfigurationUpdated(e)));
114
this.disposables.add(Event.any<{ scheme: string }>(this.fileService.onDidChangeFileSystemProviderRegistrations, this.fileService.onDidChangeFileSystemProviderCapabilities)(async e => {
115
let affected = false;
116
this.model.roots.forEach(r => {
117
if (r.resource.scheme === e.scheme) {
118
affected = true;
119
r.forgetChildren();
120
}
121
});
122
if (affected) {
123
if (this.view) {
124
await this.view.setTreeInput();
125
}
126
}
127
}));
128
this.disposables.add(this.model.onDidChangeRoots(() => {
129
this.view?.setTreeInput();
130
}));
131
132
// Refresh explorer when window gets focus to compensate for missing file events #126817
133
this.disposables.add(hostService.onDidChangeFocus(hasFocus => {
134
if (hasFocus) {
135
this.refresh(false);
136
}
137
}));
138
this.revealExcludeMatcher = new ResourceGlobMatcher(
139
(uri) => getRevealExcludes(configurationService.getValue<IFilesConfiguration>({ resource: uri })),
140
(event) => event.affectsConfiguration('explorer.autoRevealExclude'),
141
contextService, configurationService);
142
this.disposables.add(this.revealExcludeMatcher);
143
}
144
145
get roots(): ExplorerItem[] {
146
return this.model.roots;
147
}
148
149
get sortOrderConfiguration(): ISortOrderConfiguration {
150
return {
151
sortOrder: this.config.sortOrder,
152
lexicographicOptions: this.config.sortOrderLexicographicOptions,
153
reverse: this.config.sortOrderReverse,
154
};
155
}
156
157
registerView(contextProvider: IExplorerView): void {
158
this.view = contextProvider;
159
}
160
161
getContext(respectMultiSelection: boolean, ignoreNestedChildren: boolean = false): ExplorerItem[] {
162
if (!this.view) {
163
return [];
164
}
165
166
const items = new Set<ExplorerItem>(this.view.getContext(respectMultiSelection));
167
items.forEach(item => {
168
try {
169
if (respectMultiSelection && !ignoreNestedChildren && this.view?.isItemCollapsed(item) && item.nestedChildren) {
170
for (const child of item.nestedChildren) {
171
items.add(child);
172
}
173
}
174
} catch {
175
// We will error out trying to resolve collapsed nodes that have not yet been resolved.
176
// So we catch and ignore them in the multiSelect context
177
return;
178
}
179
});
180
181
return [...items];
182
}
183
184
async applyBulkEdit(edit: ResourceFileEdit[], options: { undoLabel: string; progressLabel: string; confirmBeforeUndo?: boolean; progressLocation?: ProgressLocation.Explorer | ProgressLocation.Window }): Promise<void> {
185
const cancellationTokenSource = new CancellationTokenSource();
186
const location = options.progressLocation ?? ProgressLocation.Window;
187
let progressOptions;
188
if (location === ProgressLocation.Window) {
189
progressOptions = {
190
location: location,
191
title: options.progressLabel,
192
cancellable: edit.length > 1,
193
} satisfies IProgressOptions;
194
} else {
195
progressOptions = {
196
location: location,
197
title: options.progressLabel,
198
cancellable: edit.length > 1,
199
delay: 500,
200
} satisfies IProgressCompositeOptions;
201
}
202
const promise = this.progressService.withProgress(progressOptions, async progress => {
203
await this.bulkEditService.apply(edit, {
204
undoRedoSource: UNDO_REDO_SOURCE,
205
label: options.undoLabel,
206
code: 'undoredo.explorerOperation',
207
progress,
208
token: cancellationTokenSource.token,
209
confirmBeforeUndo: options.confirmBeforeUndo
210
});
211
}, () => cancellationTokenSource.cancel());
212
await this.progressService.withProgress({ location: ProgressLocation.Explorer, delay: 500 }, () => promise);
213
cancellationTokenSource.dispose();
214
}
215
216
hasViewFocus(): boolean {
217
return !!this.view && this.view.hasFocus();
218
}
219
220
// IExplorerService methods
221
222
findClosest(resource: URI): ExplorerItem | null {
223
return this.model.findClosest(resource);
224
}
225
226
findClosestRoot(resource: URI): ExplorerItem | null {
227
const parentRoots = this.model.roots.filter(r => this.uriIdentityService.extUri.isEqualOrParent(resource, r.resource))
228
.sort((first, second) => second.resource.path.length - first.resource.path.length);
229
return parentRoots.length ? parentRoots[0] : null;
230
}
231
232
async setEditable(stat: ExplorerItem, data: IEditableData | null): Promise<void> {
233
if (!this.view) {
234
return;
235
}
236
237
if (!data) {
238
this.editable = undefined;
239
} else {
240
this.editable = { stat, data };
241
}
242
const isEditing = this.isEditable(stat);
243
try {
244
await this.view.setEditable(stat, isEditing);
245
} catch {
246
return;
247
}
248
249
250
if (!this.editable && this.fileChangeEvents.length && !this.onFileChangesScheduler.isScheduled()) {
251
this.onFileChangesScheduler.schedule();
252
}
253
}
254
255
async setToCopy(items: ExplorerItem[], cut: boolean): Promise<void> {
256
const previouslyCutItems = this.cutItems;
257
this.cutItems = cut ? items : undefined;
258
await this.clipboardService.writeResources(items.map(s => s.resource));
259
260
this.view?.itemsCopied(items, cut, previouslyCutItems);
261
}
262
263
isCut(item: ExplorerItem): boolean {
264
return !!this.cutItems && this.cutItems.some(i => this.uriIdentityService.extUri.isEqual(i.resource, item.resource));
265
}
266
267
getEditable(): { stat: ExplorerItem; data: IEditableData } | undefined {
268
return this.editable;
269
}
270
271
getEditableData(stat: ExplorerItem): IEditableData | undefined {
272
return this.editable && this.editable.stat === stat ? this.editable.data : undefined;
273
}
274
275
isEditable(stat: ExplorerItem | undefined): boolean {
276
return !!this.editable && (this.editable.stat === stat || !stat);
277
}
278
279
async select(resource: URI, reveal?: boolean | string): Promise<void> {
280
if (!this.view) {
281
return;
282
}
283
284
// If file or parent matches exclude patterns, do not reveal unless reveal argument is 'force'
285
const ignoreRevealExcludes = reveal === 'force';
286
287
const fileStat = this.findClosest(resource);
288
if (fileStat) {
289
if (!this.shouldAutoRevealItem(fileStat, ignoreRevealExcludes)) {
290
return;
291
}
292
await this.view.selectResource(fileStat.resource, reveal);
293
return Promise.resolve(undefined);
294
}
295
296
// Stat needs to be resolved first and then revealed
297
const options: IResolveFileOptions = { resolveTo: [resource], resolveMetadata: this.config.sortOrder === SortOrder.Modified };
298
const root = this.findClosestRoot(resource);
299
if (!root) {
300
return undefined;
301
}
302
303
try {
304
const stat = await this.fileService.resolve(root.resource, options);
305
306
// Convert to model
307
const modelStat = ExplorerItem.create(this.fileService, this.configurationService, this.filesConfigurationService, stat, undefined, options.resolveTo);
308
// Update Input with disk Stat
309
ExplorerItem.mergeLocalWithDisk(modelStat, root);
310
const item = root.find(resource);
311
await this.view.refresh(true, root);
312
313
// Once item is resolved, check again if folder should be expanded
314
if (item && !this.shouldAutoRevealItem(item, ignoreRevealExcludes)) {
315
return;
316
}
317
await this.view.selectResource(item ? item.resource : undefined, reveal);
318
} catch (error) {
319
root.error = error;
320
await this.view.refresh(false, root);
321
}
322
}
323
324
async refresh(reveal = true): Promise<void> {
325
// Do not refresh the tree when it is showing temporary nodes (phantom elements)
326
if (this.view?.hasPhantomElements()) {
327
return;
328
}
329
330
this.model.roots.forEach(r => r.forgetChildren());
331
if (this.view) {
332
await this.view.refresh(true);
333
const resource = this.editorService.activeEditor?.resource;
334
const autoReveal = this.configurationService.getValue<IFilesConfiguration>().explorer.autoReveal;
335
336
if (reveal && resource && autoReveal) {
337
// We did a top level refresh, reveal the active file #67118
338
this.select(resource, autoReveal);
339
}
340
}
341
}
342
343
// File events
344
345
private async onDidRunOperation(e: FileOperationEvent): Promise<void> {
346
// When nesting, changes to one file in a folder may impact the rendered structure
347
// of all the folder's immediate children, thus a recursive refresh is needed.
348
// Ideally the tree would be able to recusively refresh just one level but that does not yet exist.
349
const shouldDeepRefresh = this.config.fileNesting.enabled;
350
351
// Add
352
if (e.isOperation(FileOperation.CREATE) || e.isOperation(FileOperation.COPY)) {
353
const addedElement = e.target;
354
const parentResource = dirname(addedElement.resource)!;
355
const parents = this.model.findAll(parentResource);
356
357
if (parents.length) {
358
359
// Add the new file to its parent (Model)
360
await Promise.all(parents.map(async p => {
361
// We have to check if the parent is resolved #29177
362
const resolveMetadata = this.config.sortOrder === `modified`;
363
if (!p.isDirectoryResolved) {
364
const stat = await this.fileService.resolve(p.resource, { resolveMetadata });
365
if (stat) {
366
const modelStat = ExplorerItem.create(this.fileService, this.configurationService, this.filesConfigurationService, stat, p.parent);
367
ExplorerItem.mergeLocalWithDisk(modelStat, p);
368
}
369
}
370
371
const childElement = ExplorerItem.create(this.fileService, this.configurationService, this.filesConfigurationService, addedElement, p.parent);
372
// Make sure to remove any previous version of the file if any
373
p.removeChild(childElement);
374
p.addChild(childElement);
375
// Refresh the Parent (View)
376
await this.view?.refresh(shouldDeepRefresh, p);
377
}));
378
}
379
}
380
381
// Move (including Rename)
382
else if (e.isOperation(FileOperation.MOVE)) {
383
const oldResource = e.resource;
384
const newElement = e.target;
385
const oldParentResource = dirname(oldResource);
386
const newParentResource = dirname(newElement.resource);
387
const modelElements = this.model.findAll(oldResource);
388
const sameParentMove = modelElements.every(e => !e.nestedParent) && this.uriIdentityService.extUri.isEqual(oldParentResource, newParentResource);
389
390
// Handle Rename
391
if (sameParentMove) {
392
await Promise.all(modelElements.map(async modelElement => {
393
// Rename File (Model)
394
modelElement.rename(newElement);
395
await this.view?.refresh(shouldDeepRefresh, modelElement.parent);
396
}));
397
}
398
399
// Handle Move
400
else {
401
const newParents = this.model.findAll(newParentResource);
402
if (newParents.length && modelElements.length) {
403
// Move in Model
404
await Promise.all(modelElements.map(async (modelElement, index) => {
405
const oldParent = modelElement.parent;
406
const oldNestedParent = modelElement.nestedParent;
407
modelElement.move(newParents[index]);
408
if (oldNestedParent) {
409
await this.view?.refresh(false, oldNestedParent);
410
}
411
await this.view?.refresh(false, oldParent);
412
await this.view?.refresh(shouldDeepRefresh, newParents[index]);
413
}));
414
}
415
}
416
}
417
418
// Delete
419
else if (e.isOperation(FileOperation.DELETE)) {
420
const modelElements = this.model.findAll(e.resource);
421
await Promise.all(modelElements.map(async modelElement => {
422
if (modelElement.parent) {
423
// Remove Element from Parent (Model)
424
const parent = modelElement.parent;
425
parent.removeChild(modelElement);
426
this.view?.focusNext();
427
428
const oldNestedParent = modelElement.nestedParent;
429
if (oldNestedParent) {
430
oldNestedParent.removeChild(modelElement);
431
await this.view?.refresh(false, oldNestedParent);
432
}
433
// Refresh Parent (View)
434
await this.view?.refresh(shouldDeepRefresh, parent);
435
436
if (this.view?.getFocus().length === 0) {
437
this.view?.focusLast();
438
}
439
}
440
}));
441
}
442
}
443
444
// Check if an item matches a explorer.autoRevealExclude pattern
445
private shouldAutoRevealItem(item: ExplorerItem | undefined, ignore: boolean): boolean {
446
if (item === undefined || ignore) {
447
return true;
448
}
449
if (this.revealExcludeMatcher.matches(item.resource, name => !!(item.parent && item.parent.getChild(name)))) {
450
return false;
451
}
452
const root = item.root;
453
let currentItem = item.parent;
454
while (currentItem !== root) {
455
if (currentItem === undefined) {
456
return true;
457
}
458
if (this.revealExcludeMatcher.matches(currentItem.resource)) {
459
return false;
460
}
461
currentItem = currentItem.parent;
462
}
463
return true;
464
}
465
466
private async onConfigurationUpdated(event: IConfigurationChangeEvent): Promise<void> {
467
if (!event.affectsConfiguration('explorer')) {
468
return;
469
}
470
471
let shouldRefresh = false;
472
473
if (event.affectsConfiguration('explorer.fileNesting')) {
474
shouldRefresh = true;
475
}
476
477
const configuration = this.configurationService.getValue<IFilesConfiguration>();
478
479
const configSortOrder = configuration?.explorer?.sortOrder || SortOrder.Default;
480
if (this.config.sortOrder !== configSortOrder) {
481
shouldRefresh = this.config.sortOrder !== undefined;
482
}
483
484
const configLexicographicOptions = configuration?.explorer?.sortOrderLexicographicOptions || LexicographicOptions.Default;
485
if (this.config.sortOrderLexicographicOptions !== configLexicographicOptions) {
486
shouldRefresh = shouldRefresh || this.config.sortOrderLexicographicOptions !== undefined;
487
}
488
const sortOrderReverse = configuration?.explorer?.sortOrderReverse || false;
489
490
if (this.config.sortOrderReverse !== sortOrderReverse) {
491
shouldRefresh = shouldRefresh || this.config.sortOrderReverse !== undefined;
492
}
493
494
this.config = configuration.explorer;
495
496
if (shouldRefresh) {
497
await this.refresh();
498
}
499
}
500
501
dispose(): void {
502
this.disposables.dispose();
503
}
504
}
505
506
function doesFileEventAffect(item: ExplorerItem, view: IExplorerView, events: FileChangesEvent[], types: FileChangeType[]): boolean {
507
for (const [_name, child] of item.children) {
508
if (view.isItemVisible(child)) {
509
if (events.some(e => e.contains(child.resource, ...types))) {
510
return true;
511
}
512
if (child.isDirectory && child.isDirectoryResolved) {
513
if (doesFileEventAffect(child, view, events, types)) {
514
return true;
515
}
516
}
517
}
518
}
519
520
return false;
521
}
522
523
function getRevealExcludes(configuration: IFilesConfiguration): IExpression {
524
const revealExcludes = configuration && configuration.explorer && configuration.explorer.autoRevealExclude;
525
526
if (!revealExcludes) {
527
return {};
528
}
529
530
return revealExcludes;
531
}
532
533