Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/files/common/explorerModel.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 { URI } from '../../../../base/common/uri.js';
7
import { isEqual } from '../../../../base/common/extpath.js';
8
import { posix } from '../../../../base/common/path.js';
9
import { ResourceMap } from '../../../../base/common/map.js';
10
import { IFileStat, IFileService, FileSystemProviderCapabilities } from '../../../../platform/files/common/files.js';
11
import { rtrim, startsWithIgnoreCase, equalsIgnoreCase } from '../../../../base/common/strings.js';
12
import { coalesce } from '../../../../base/common/arrays.js';
13
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
14
import { IDisposable, dispose } from '../../../../base/common/lifecycle.js';
15
import { memoize } from '../../../../base/common/decorators.js';
16
import { Emitter, Event } from '../../../../base/common/event.js';
17
import { joinPath, isEqualOrParent, basenameOrAuthority } from '../../../../base/common/resources.js';
18
import { IFilesConfiguration, SortOrder } from './files.js';
19
import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js';
20
import { ExplorerFileNestingTrie } from './explorerFileNestingTrie.js';
21
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
22
import { assertReturnsDefined } from '../../../../base/common/types.js';
23
import { IFilesConfigurationService } from '../../../services/filesConfiguration/common/filesConfigurationService.js';
24
import { IMarkdownString } from '../../../../base/common/htmlContent.js';
25
26
export class ExplorerModel implements IDisposable {
27
28
private _roots!: ExplorerItem[];
29
private _listener: IDisposable;
30
private readonly _onDidChangeRoots = new Emitter<void>();
31
32
constructor(
33
private readonly contextService: IWorkspaceContextService,
34
private readonly uriIdentityService: IUriIdentityService,
35
fileService: IFileService,
36
configService: IConfigurationService,
37
filesConfigService: IFilesConfigurationService,
38
) {
39
const setRoots = () => this._roots = this.contextService.getWorkspace().folders
40
.map(folder => new ExplorerItem(folder.uri, fileService, configService, filesConfigService, undefined, true, false, false, false, folder.name));
41
setRoots();
42
43
this._listener = this.contextService.onDidChangeWorkspaceFolders(() => {
44
setRoots();
45
this._onDidChangeRoots.fire();
46
});
47
}
48
49
get roots(): ExplorerItem[] {
50
return this._roots;
51
}
52
53
get onDidChangeRoots(): Event<void> {
54
return this._onDidChangeRoots.event;
55
}
56
57
/**
58
* Returns an array of child stat from this stat that matches with the provided path.
59
* Starts matching from the first root.
60
* Will return empty array in case the FileStat does not exist.
61
*/
62
findAll(resource: URI): ExplorerItem[] {
63
return coalesce(this.roots.map(root => root.find(resource)));
64
}
65
66
/**
67
* Returns a FileStat that matches the passed resource.
68
* In case multiple FileStat are matching the resource (same folder opened multiple times) returns the FileStat that has the closest root.
69
* Will return undefined in case the FileStat does not exist.
70
*/
71
findClosest(resource: URI): ExplorerItem | null {
72
const folder = this.contextService.getWorkspaceFolder(resource);
73
if (folder) {
74
const root = this.roots.find(r => this.uriIdentityService.extUri.isEqual(r.resource, folder.uri));
75
if (root) {
76
return root.find(resource);
77
}
78
}
79
80
return null;
81
}
82
83
dispose(): void {
84
dispose(this._listener);
85
}
86
}
87
88
export class ExplorerItem {
89
_isDirectoryResolved: boolean; // used in tests
90
public error: Error | undefined = undefined;
91
private _isExcluded = false;
92
93
public nestedParent: ExplorerItem | undefined;
94
public nestedChildren: ExplorerItem[] | undefined;
95
96
constructor(
97
public resource: URI,
98
private readonly fileService: IFileService,
99
private readonly configService: IConfigurationService,
100
private readonly filesConfigService: IFilesConfigurationService,
101
private _parent: ExplorerItem | undefined,
102
private _isDirectory?: boolean,
103
private _isSymbolicLink?: boolean,
104
private _readonly?: boolean,
105
private _locked?: boolean,
106
private _name: string = basenameOrAuthority(resource),
107
private _mtime?: number,
108
private _unknown = false
109
) {
110
this._isDirectoryResolved = false;
111
}
112
113
get isExcluded(): boolean {
114
if (this._isExcluded) {
115
return true;
116
}
117
if (!this._parent) {
118
return false;
119
}
120
121
return this._parent.isExcluded;
122
}
123
124
set isExcluded(value: boolean) {
125
this._isExcluded = value;
126
}
127
128
hasChildren(filter: (stat: ExplorerItem) => boolean): boolean {
129
if (this.hasNests) {
130
return this.nestedChildren?.some(c => filter(c)) ?? false;
131
} else {
132
return this.isDirectory;
133
}
134
}
135
136
get hasNests() {
137
return !!(this.nestedChildren?.length);
138
}
139
140
get isDirectoryResolved(): boolean {
141
return this._isDirectoryResolved;
142
}
143
144
get isSymbolicLink(): boolean {
145
return !!this._isSymbolicLink;
146
}
147
148
get isDirectory(): boolean {
149
return !!this._isDirectory;
150
}
151
152
get isReadonly(): boolean | IMarkdownString {
153
return this.filesConfigService.isReadonly(this.resource, { resource: this.resource, name: this.name, readonly: this._readonly, locked: this._locked });
154
}
155
156
get mtime(): number | undefined {
157
return this._mtime;
158
}
159
160
get name(): string {
161
return this._name;
162
}
163
164
get isUnknown(): boolean {
165
return this._unknown;
166
}
167
168
get parent(): ExplorerItem | undefined {
169
return this._parent;
170
}
171
172
get root(): ExplorerItem {
173
if (!this._parent) {
174
return this;
175
}
176
177
return this._parent.root;
178
}
179
180
@memoize get children(): Map<string, ExplorerItem> {
181
return new Map<string, ExplorerItem>();
182
}
183
184
private updateName(value: string): void {
185
// Re-add to parent since the parent has a name map to children and the name might have changed
186
this._parent?.removeChild(this);
187
this._name = value;
188
this._parent?.addChild(this);
189
}
190
191
getId(): string {
192
let id = this.root.resource.toString() + '::' + this.resource.toString();
193
194
if (this.isMarkedAsFiltered()) {
195
id += '::findFilterResult';
196
}
197
198
return id;
199
}
200
201
toString(): string {
202
return `ExplorerItem: ${this.name}`;
203
}
204
205
get isRoot(): boolean {
206
return this === this.root;
207
}
208
209
static create(fileService: IFileService, configService: IConfigurationService, filesConfigService: IFilesConfigurationService, raw: IFileStat, parent: ExplorerItem | undefined, resolveTo?: readonly URI[]): ExplorerItem {
210
const stat = new ExplorerItem(raw.resource, fileService, configService, filesConfigService, parent, raw.isDirectory, raw.isSymbolicLink, raw.readonly, raw.locked, raw.name, raw.mtime, !raw.isFile && !raw.isDirectory);
211
212
// Recursively add children if present
213
if (stat.isDirectory) {
214
215
// isDirectoryResolved is a very important indicator in the stat model that tells if the folder was fully resolved
216
// the folder is fully resolved if either it has a list of children or the client requested this by using the resolveTo
217
// array of resource path to resolve.
218
stat._isDirectoryResolved = !!raw.children || (!!resolveTo && resolveTo.some((r) => {
219
return isEqualOrParent(r, stat.resource);
220
}));
221
222
// Recurse into children
223
if (raw.children) {
224
for (let i = 0, len = raw.children.length; i < len; i++) {
225
const child = ExplorerItem.create(fileService, configService, filesConfigService, raw.children[i], stat, resolveTo);
226
stat.addChild(child);
227
}
228
}
229
}
230
231
return stat;
232
}
233
234
/**
235
* Merges the stat which was resolved from the disk with the local stat by copying over properties
236
* and children. The merge will only consider resolved stat elements to avoid overwriting data which
237
* exists locally.
238
*/
239
static mergeLocalWithDisk(disk: ExplorerItem, local: ExplorerItem): void {
240
if (disk.resource.toString() !== local.resource.toString()) {
241
return; // Merging only supported for stats with the same resource
242
}
243
244
// Stop merging when a folder is not resolved to avoid loosing local data
245
const mergingDirectories = disk.isDirectory || local.isDirectory;
246
if (mergingDirectories && local._isDirectoryResolved && !disk._isDirectoryResolved) {
247
return;
248
}
249
250
// Properties
251
local.resource = disk.resource;
252
if (!local.isRoot) {
253
local.updateName(disk.name);
254
}
255
local._isDirectory = disk.isDirectory;
256
local._mtime = disk.mtime;
257
local._isDirectoryResolved = disk._isDirectoryResolved;
258
local._isSymbolicLink = disk.isSymbolicLink;
259
local.error = disk.error;
260
261
// Merge Children if resolved
262
if (mergingDirectories && disk._isDirectoryResolved) {
263
264
// Map resource => stat
265
const oldLocalChildren = new ResourceMap<ExplorerItem>();
266
local.children.forEach(child => {
267
oldLocalChildren.set(child.resource, child);
268
});
269
270
// Clear current children
271
local.children.clear();
272
273
// Merge received children
274
disk.children.forEach(diskChild => {
275
const formerLocalChild = oldLocalChildren.get(diskChild.resource);
276
// Existing child: merge
277
if (formerLocalChild) {
278
ExplorerItem.mergeLocalWithDisk(diskChild, formerLocalChild);
279
local.addChild(formerLocalChild);
280
oldLocalChildren.delete(diskChild.resource);
281
}
282
283
// New child: add
284
else {
285
local.addChild(diskChild);
286
}
287
});
288
289
oldLocalChildren.forEach(oldChild => {
290
if (oldChild instanceof NewExplorerItem) {
291
local.addChild(oldChild);
292
}
293
});
294
}
295
}
296
297
/**
298
* Adds a child element to this folder.
299
*/
300
addChild(child: ExplorerItem): void {
301
// Inherit some parent properties to child
302
child._parent = this;
303
child.updateResource(false);
304
this.children.set(this.getPlatformAwareName(child.name), child);
305
}
306
307
getChild(name: string): ExplorerItem | undefined {
308
return this.children.get(this.getPlatformAwareName(name));
309
}
310
311
fetchChildren(sortOrder: SortOrder): ExplorerItem[] | Promise<ExplorerItem[]> {
312
const nestingConfig = this.configService.getValue<IFilesConfiguration>({ resource: this.root.resource }).explorer.fileNesting;
313
314
// fast path when the children can be resolved sync
315
if (nestingConfig.enabled && this.nestedChildren) {
316
return this.nestedChildren;
317
}
318
319
return (async () => {
320
if (!this._isDirectoryResolved) {
321
// Resolve metadata only when the mtime is needed since this can be expensive
322
// Mtime is only used when the sort order is 'modified'
323
const resolveMetadata = sortOrder === SortOrder.Modified;
324
this.error = undefined;
325
try {
326
const stat = await this.fileService.resolve(this.resource, { resolveSingleChildDescendants: true, resolveMetadata });
327
const resolved = ExplorerItem.create(this.fileService, this.configService, this.filesConfigService, stat, this);
328
ExplorerItem.mergeLocalWithDisk(resolved, this);
329
} catch (e) {
330
this.error = e;
331
throw e;
332
}
333
this._isDirectoryResolved = true;
334
}
335
336
const items: ExplorerItem[] = [];
337
if (nestingConfig.enabled) {
338
const fileChildren: [string, ExplorerItem][] = [];
339
const dirChildren: [string, ExplorerItem][] = [];
340
for (const child of this.children.entries()) {
341
child[1].nestedParent = undefined;
342
if (child[1].isDirectory) {
343
dirChildren.push(child);
344
} else {
345
fileChildren.push(child);
346
}
347
}
348
349
const nested = this.fileNester.nest(
350
fileChildren.map(([name]) => name),
351
this.getPlatformAwareName(this.name));
352
353
for (const [fileEntryName, fileEntryItem] of fileChildren) {
354
const nestedItems = nested.get(fileEntryName);
355
if (nestedItems !== undefined) {
356
fileEntryItem.nestedChildren = [];
357
for (const name of nestedItems.keys()) {
358
const child = assertReturnsDefined(this.children.get(name));
359
fileEntryItem.nestedChildren.push(child);
360
child.nestedParent = fileEntryItem;
361
}
362
items.push(fileEntryItem);
363
} else {
364
fileEntryItem.nestedChildren = undefined;
365
}
366
}
367
368
for (const [_, dirEntryItem] of dirChildren.values()) {
369
items.push(dirEntryItem);
370
}
371
} else {
372
this.children.forEach(child => {
373
items.push(child);
374
});
375
}
376
return items;
377
})();
378
}
379
380
private _fileNester: ExplorerFileNestingTrie | undefined;
381
private get fileNester(): ExplorerFileNestingTrie {
382
if (!this.root._fileNester) {
383
const nestingConfig = this.configService.getValue<IFilesConfiguration>({ resource: this.root.resource }).explorer.fileNesting;
384
const patterns = Object.entries(nestingConfig.patterns)
385
.filter(entry =>
386
typeof (entry[0]) === 'string' && typeof (entry[1]) === 'string' && entry[0] && entry[1])
387
.map(([parentPattern, childrenPatterns]) =>
388
[
389
this.getPlatformAwareName(parentPattern.trim()),
390
childrenPatterns.split(',').map(p => this.getPlatformAwareName(p.trim().replace(/\u200b/g, '').trim()))
391
.filter(p => p !== '')
392
] as [string, string[]]);
393
394
this.root._fileNester = new ExplorerFileNestingTrie(patterns);
395
}
396
return this.root._fileNester;
397
}
398
399
/**
400
* Removes a child element from this folder.
401
*/
402
removeChild(child: ExplorerItem): void {
403
this.nestedChildren = undefined;
404
this.children.delete(this.getPlatformAwareName(child.name));
405
}
406
407
forgetChildren(): void {
408
this.children.clear();
409
this.nestedChildren = undefined;
410
this._isDirectoryResolved = false;
411
this._fileNester = undefined;
412
}
413
414
private getPlatformAwareName(name: string): string {
415
return this.fileService.hasCapability(this.resource, FileSystemProviderCapabilities.PathCaseSensitive) ? name : name.toLowerCase();
416
}
417
418
/**
419
* Moves this element under a new parent element.
420
*/
421
move(newParent: ExplorerItem): void {
422
this.nestedParent?.removeChild(this);
423
this._parent?.removeChild(this);
424
newParent.removeChild(this); // make sure to remove any previous version of the file if any
425
newParent.addChild(this);
426
this.updateResource(true);
427
}
428
429
private updateResource(recursive: boolean): void {
430
if (this._parent) {
431
this.resource = joinPath(this._parent.resource, this.name);
432
}
433
434
if (recursive) {
435
if (this.isDirectory) {
436
this.children.forEach(child => {
437
child.updateResource(true);
438
});
439
}
440
}
441
}
442
443
/**
444
* Tells this stat that it was renamed. This requires changes to all children of this stat (if any)
445
* so that the path property can be updated properly.
446
*/
447
rename(renamedStat: { name: string; mtime?: number }): void {
448
449
// Merge a subset of Properties that can change on rename
450
this.updateName(renamedStat.name);
451
this._mtime = renamedStat.mtime;
452
453
// Update Paths including children
454
this.updateResource(true);
455
}
456
457
/**
458
* Returns a child stat from this stat that matches with the provided path.
459
* Will return "null" in case the child does not exist.
460
*/
461
find(resource: URI): ExplorerItem | null {
462
// Return if path found
463
// For performance reasons try to do the comparison as fast as possible
464
const ignoreCase = !this.fileService.hasCapability(resource, FileSystemProviderCapabilities.PathCaseSensitive);
465
if (resource && this.resource.scheme === resource.scheme && equalsIgnoreCase(this.resource.authority, resource.authority) &&
466
(ignoreCase ? startsWithIgnoreCase(resource.path, this.resource.path) : resource.path.startsWith(this.resource.path))) {
467
return this.findByPath(rtrim(resource.path, posix.sep), this.resource.path.length, ignoreCase);
468
}
469
470
return null; //Unable to find
471
}
472
473
private findByPath(path: string, index: number, ignoreCase: boolean): ExplorerItem | null {
474
if (isEqual(rtrim(this.resource.path, posix.sep), path, ignoreCase)) {
475
return this;
476
}
477
478
if (this.isDirectory) {
479
// Ignore separtor to more easily deduct the next name to search
480
while (index < path.length && path[index] === posix.sep) {
481
index++;
482
}
483
484
let indexOfNextSep = path.indexOf(posix.sep, index);
485
if (indexOfNextSep === -1) {
486
// If there is no separator take the remainder of the path
487
indexOfNextSep = path.length;
488
}
489
// The name to search is between two separators
490
const name = path.substring(index, indexOfNextSep);
491
492
const child = this.children.get(this.getPlatformAwareName(name));
493
494
if (child) {
495
// We found a child with the given name, search inside it
496
return child.findByPath(path, indexOfNextSep, ignoreCase);
497
}
498
}
499
500
return null;
501
}
502
503
// Find
504
private markedAsFindResult = false;
505
isMarkedAsFiltered(): boolean {
506
return this.markedAsFindResult;
507
}
508
509
markItemAndParentsAsFiltered(): void {
510
this.markedAsFindResult = true;
511
this.parent?.markItemAndParentsAsFiltered();
512
}
513
514
unmarkItemAndChildren(): void {
515
this.markedAsFindResult = false;
516
this.children.forEach(child => child.unmarkItemAndChildren());
517
}
518
}
519
520
export class NewExplorerItem extends ExplorerItem {
521
constructor(fileService: IFileService, configService: IConfigurationService, filesConfigService: IFilesConfigurationService, parent: ExplorerItem, isDirectory: boolean) {
522
super(URI.file(''), fileService, configService, filesConfigService, parent, isDirectory);
523
this._isDirectoryResolved = true;
524
}
525
}
526
527