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