Path: blob/main/src/vs/workbench/contrib/files/common/explorerModel.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { URI } from '../../../../base/common/uri.js';6import { isEqual } from '../../../../base/common/extpath.js';7import { posix } from '../../../../base/common/path.js';8import { ResourceMap } from '../../../../base/common/map.js';9import { IFileStat, IFileService, FileSystemProviderCapabilities } from '../../../../platform/files/common/files.js';10import { rtrim, startsWithIgnoreCase, equalsIgnoreCase } from '../../../../base/common/strings.js';11import { coalesce } from '../../../../base/common/arrays.js';12import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';13import { IDisposable, dispose } from '../../../../base/common/lifecycle.js';14import { memoize } from '../../../../base/common/decorators.js';15import { Emitter, Event } from '../../../../base/common/event.js';16import { joinPath, isEqualOrParent, basenameOrAuthority } from '../../../../base/common/resources.js';17import { IFilesConfiguration, SortOrder } from './files.js';18import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js';19import { ExplorerFileNestingTrie } from './explorerFileNestingTrie.js';20import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';21import { assertReturnsDefined } from '../../../../base/common/types.js';22import { IFilesConfigurationService } from '../../../services/filesConfiguration/common/filesConfigurationService.js';23import { IMarkdownString } from '../../../../base/common/htmlContent.js';2425export class ExplorerModel implements IDisposable {2627private _roots!: ExplorerItem[];28private _listener: IDisposable;29private readonly _onDidChangeRoots = new Emitter<void>();3031constructor(32private readonly contextService: IWorkspaceContextService,33private readonly uriIdentityService: IUriIdentityService,34fileService: IFileService,35configService: IConfigurationService,36filesConfigService: IFilesConfigurationService,37) {38const setRoots = () => this._roots = this.contextService.getWorkspace().folders39.map(folder => new ExplorerItem(folder.uri, fileService, configService, filesConfigService, undefined, true, false, false, false, folder.name));40setRoots();4142this._listener = this.contextService.onDidChangeWorkspaceFolders(() => {43setRoots();44this._onDidChangeRoots.fire();45});46}4748get roots(): ExplorerItem[] {49return this._roots;50}5152get onDidChangeRoots(): Event<void> {53return this._onDidChangeRoots.event;54}5556/**57* Returns an array of child stat from this stat that matches with the provided path.58* Starts matching from the first root.59* Will return empty array in case the FileStat does not exist.60*/61findAll(resource: URI): ExplorerItem[] {62return coalesce(this.roots.map(root => root.find(resource)));63}6465/**66* Returns a FileStat that matches the passed resource.67* In case multiple FileStat are matching the resource (same folder opened multiple times) returns the FileStat that has the closest root.68* Will return undefined in case the FileStat does not exist.69*/70findClosest(resource: URI): ExplorerItem | null {71const folder = this.contextService.getWorkspaceFolder(resource);72if (folder) {73const root = this.roots.find(r => this.uriIdentityService.extUri.isEqual(r.resource, folder.uri));74if (root) {75return root.find(resource);76}77}7879return null;80}8182dispose(): void {83dispose(this._listener);84}85}8687export class ExplorerItem {88_isDirectoryResolved: boolean; // used in tests89public error: Error | undefined = undefined;90private _isExcluded = false;9192public nestedParent: ExplorerItem | undefined;93public nestedChildren: ExplorerItem[] | undefined;9495constructor(96public resource: URI,97private readonly fileService: IFileService,98private readonly configService: IConfigurationService,99private readonly filesConfigService: IFilesConfigurationService,100private _parent: ExplorerItem | undefined,101private _isDirectory?: boolean,102private _isSymbolicLink?: boolean,103private _readonly?: boolean,104private _locked?: boolean,105private _name: string = basenameOrAuthority(resource),106private _mtime?: number,107private _unknown = false108) {109this._isDirectoryResolved = false;110}111112get isExcluded(): boolean {113if (this._isExcluded) {114return true;115}116if (!this._parent) {117return false;118}119120return this._parent.isExcluded;121}122123set isExcluded(value: boolean) {124this._isExcluded = value;125}126127hasChildren(filter: (stat: ExplorerItem) => boolean): boolean {128if (this.hasNests) {129return this.nestedChildren?.some(c => filter(c)) ?? false;130} else {131return this.isDirectory;132}133}134135get hasNests() {136return !!(this.nestedChildren?.length);137}138139get isDirectoryResolved(): boolean {140return this._isDirectoryResolved;141}142143get isSymbolicLink(): boolean {144return !!this._isSymbolicLink;145}146147get isDirectory(): boolean {148return !!this._isDirectory;149}150151get isReadonly(): boolean | IMarkdownString {152return this.filesConfigService.isReadonly(this.resource, { resource: this.resource, name: this.name, readonly: this._readonly, locked: this._locked });153}154155get mtime(): number | undefined {156return this._mtime;157}158159get name(): string {160return this._name;161}162163get isUnknown(): boolean {164return this._unknown;165}166167get parent(): ExplorerItem | undefined {168return this._parent;169}170171get root(): ExplorerItem {172if (!this._parent) {173return this;174}175176return this._parent.root;177}178179@memoize get children(): Map<string, ExplorerItem> {180return new Map<string, ExplorerItem>();181}182183private updateName(value: string): void {184// Re-add to parent since the parent has a name map to children and the name might have changed185this._parent?.removeChild(this);186this._name = value;187this._parent?.addChild(this);188}189190getId(): string {191let id = this.root.resource.toString() + '::' + this.resource.toString();192193if (this.isMarkedAsFiltered()) {194id += '::findFilterResult';195}196197return id;198}199200toString(): string {201return `ExplorerItem: ${this.name}`;202}203204get isRoot(): boolean {205return this === this.root;206}207208static create(fileService: IFileService, configService: IConfigurationService, filesConfigService: IFilesConfigurationService, raw: IFileStat, parent: ExplorerItem | undefined, resolveTo?: readonly URI[]): ExplorerItem {209const 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);210211// Recursively add children if present212if (stat.isDirectory) {213214// isDirectoryResolved is a very important indicator in the stat model that tells if the folder was fully resolved215// the folder is fully resolved if either it has a list of children or the client requested this by using the resolveTo216// array of resource path to resolve.217stat._isDirectoryResolved = !!raw.children || (!!resolveTo && resolveTo.some((r) => {218return isEqualOrParent(r, stat.resource);219}));220221// Recurse into children222if (raw.children) {223for (let i = 0, len = raw.children.length; i < len; i++) {224const child = ExplorerItem.create(fileService, configService, filesConfigService, raw.children[i], stat, resolveTo);225stat.addChild(child);226}227}228}229230return stat;231}232233/**234* Merges the stat which was resolved from the disk with the local stat by copying over properties235* and children. The merge will only consider resolved stat elements to avoid overwriting data which236* exists locally.237*/238static mergeLocalWithDisk(disk: ExplorerItem, local: ExplorerItem): void {239if (disk.resource.toString() !== local.resource.toString()) {240return; // Merging only supported for stats with the same resource241}242243// Stop merging when a folder is not resolved to avoid loosing local data244const mergingDirectories = disk.isDirectory || local.isDirectory;245if (mergingDirectories && local._isDirectoryResolved && !disk._isDirectoryResolved) {246return;247}248249// Properties250local.resource = disk.resource;251if (!local.isRoot) {252local.updateName(disk.name);253}254local._isDirectory = disk.isDirectory;255local._mtime = disk.mtime;256local._isDirectoryResolved = disk._isDirectoryResolved;257local._isSymbolicLink = disk.isSymbolicLink;258local.error = disk.error;259260// Merge Children if resolved261if (mergingDirectories && disk._isDirectoryResolved) {262263// Map resource => stat264const oldLocalChildren = new ResourceMap<ExplorerItem>();265local.children.forEach(child => {266oldLocalChildren.set(child.resource, child);267});268269// Clear current children270local.children.clear();271272// Merge received children273disk.children.forEach(diskChild => {274const formerLocalChild = oldLocalChildren.get(diskChild.resource);275// Existing child: merge276if (formerLocalChild) {277ExplorerItem.mergeLocalWithDisk(diskChild, formerLocalChild);278local.addChild(formerLocalChild);279oldLocalChildren.delete(diskChild.resource);280}281282// New child: add283else {284local.addChild(diskChild);285}286});287288oldLocalChildren.forEach(oldChild => {289if (oldChild instanceof NewExplorerItem) {290local.addChild(oldChild);291}292});293}294}295296/**297* Adds a child element to this folder.298*/299addChild(child: ExplorerItem): void {300// Inherit some parent properties to child301child._parent = this;302child.updateResource(false);303this.children.set(this.getPlatformAwareName(child.name), child);304}305306getChild(name: string): ExplorerItem | undefined {307return this.children.get(this.getPlatformAwareName(name));308}309310fetchChildren(sortOrder: SortOrder): ExplorerItem[] | Promise<ExplorerItem[]> {311const nestingConfig = this.configService.getValue<IFilesConfiguration>({ resource: this.root.resource }).explorer.fileNesting;312313// fast path when the children can be resolved sync314if (nestingConfig.enabled && this.nestedChildren) {315return this.nestedChildren;316}317318return (async () => {319if (!this._isDirectoryResolved) {320// Resolve metadata only when the mtime is needed since this can be expensive321// Mtime is only used when the sort order is 'modified'322const resolveMetadata = sortOrder === SortOrder.Modified;323this.error = undefined;324try {325const stat = await this.fileService.resolve(this.resource, { resolveSingleChildDescendants: true, resolveMetadata });326const resolved = ExplorerItem.create(this.fileService, this.configService, this.filesConfigService, stat, this);327ExplorerItem.mergeLocalWithDisk(resolved, this);328} catch (e) {329this.error = e;330throw e;331}332this._isDirectoryResolved = true;333}334335const items: ExplorerItem[] = [];336if (nestingConfig.enabled) {337const fileChildren: [string, ExplorerItem][] = [];338const dirChildren: [string, ExplorerItem][] = [];339for (const child of this.children.entries()) {340child[1].nestedParent = undefined;341if (child[1].isDirectory) {342dirChildren.push(child);343} else {344fileChildren.push(child);345}346}347348const nested = this.fileNester.nest(349fileChildren.map(([name]) => name),350this.getPlatformAwareName(this.name));351352for (const [fileEntryName, fileEntryItem] of fileChildren) {353const nestedItems = nested.get(fileEntryName);354if (nestedItems !== undefined) {355fileEntryItem.nestedChildren = [];356for (const name of nestedItems.keys()) {357const child = assertReturnsDefined(this.children.get(name));358fileEntryItem.nestedChildren.push(child);359child.nestedParent = fileEntryItem;360}361items.push(fileEntryItem);362} else {363fileEntryItem.nestedChildren = undefined;364}365}366367for (const [_, dirEntryItem] of dirChildren.values()) {368items.push(dirEntryItem);369}370} else {371this.children.forEach(child => {372items.push(child);373});374}375return items;376})();377}378379private _fileNester: ExplorerFileNestingTrie | undefined;380private get fileNester(): ExplorerFileNestingTrie {381if (!this.root._fileNester) {382const nestingConfig = this.configService.getValue<IFilesConfiguration>({ resource: this.root.resource }).explorer.fileNesting;383const patterns = Object.entries(nestingConfig.patterns)384.filter(entry =>385typeof (entry[0]) === 'string' && typeof (entry[1]) === 'string' && entry[0] && entry[1])386.map(([parentPattern, childrenPatterns]) =>387[388this.getPlatformAwareName(parentPattern.trim()),389childrenPatterns.split(',').map(p => this.getPlatformAwareName(p.trim().replace(/\u200b/g, '').trim()))390.filter(p => p !== '')391] as [string, string[]]);392393this.root._fileNester = new ExplorerFileNestingTrie(patterns);394}395return this.root._fileNester;396}397398/**399* Removes a child element from this folder.400*/401removeChild(child: ExplorerItem): void {402this.nestedChildren = undefined;403this.children.delete(this.getPlatformAwareName(child.name));404}405406forgetChildren(): void {407this.children.clear();408this.nestedChildren = undefined;409this._isDirectoryResolved = false;410this._fileNester = undefined;411}412413private getPlatformAwareName(name: string): string {414return this.fileService.hasCapability(this.resource, FileSystemProviderCapabilities.PathCaseSensitive) ? name : name.toLowerCase();415}416417/**418* Moves this element under a new parent element.419*/420move(newParent: ExplorerItem): void {421this.nestedParent?.removeChild(this);422this._parent?.removeChild(this);423newParent.removeChild(this); // make sure to remove any previous version of the file if any424newParent.addChild(this);425this.updateResource(true);426}427428private updateResource(recursive: boolean): void {429if (this._parent) {430this.resource = joinPath(this._parent.resource, this.name);431}432433if (recursive) {434if (this.isDirectory) {435this.children.forEach(child => {436child.updateResource(true);437});438}439}440}441442/**443* Tells this stat that it was renamed. This requires changes to all children of this stat (if any)444* so that the path property can be updated properly.445*/446rename(renamedStat: { name: string; mtime?: number }): void {447448// Merge a subset of Properties that can change on rename449this.updateName(renamedStat.name);450this._mtime = renamedStat.mtime;451452// Update Paths including children453this.updateResource(true);454}455456/**457* Returns a child stat from this stat that matches with the provided path.458* Will return "null" in case the child does not exist.459*/460find(resource: URI): ExplorerItem | null {461// Return if path found462// For performance reasons try to do the comparison as fast as possible463const ignoreCase = !this.fileService.hasCapability(resource, FileSystemProviderCapabilities.PathCaseSensitive);464if (resource && this.resource.scheme === resource.scheme && equalsIgnoreCase(this.resource.authority, resource.authority) &&465(ignoreCase ? startsWithIgnoreCase(resource.path, this.resource.path) : resource.path.startsWith(this.resource.path))) {466return this.findByPath(rtrim(resource.path, posix.sep), this.resource.path.length, ignoreCase);467}468469return null; //Unable to find470}471472private findByPath(path: string, index: number, ignoreCase: boolean): ExplorerItem | null {473if (isEqual(rtrim(this.resource.path, posix.sep), path, ignoreCase)) {474return this;475}476477if (this.isDirectory) {478// Ignore separtor to more easily deduct the next name to search479while (index < path.length && path[index] === posix.sep) {480index++;481}482483let indexOfNextSep = path.indexOf(posix.sep, index);484if (indexOfNextSep === -1) {485// If there is no separator take the remainder of the path486indexOfNextSep = path.length;487}488// The name to search is between two separators489const name = path.substring(index, indexOfNextSep);490491const child = this.children.get(this.getPlatformAwareName(name));492493if (child) {494// We found a child with the given name, search inside it495return child.findByPath(path, indexOfNextSep, ignoreCase);496}497}498499return null;500}501502// Find503private markedAsFindResult = false;504isMarkedAsFiltered(): boolean {505return this.markedAsFindResult;506}507508markItemAndParentsAsFiltered(): void {509this.markedAsFindResult = true;510this.parent?.markItemAndParentsAsFiltered();511}512513unmarkItemAndChildren(): void {514this.markedAsFindResult = false;515this.children.forEach(child => child.unmarkItemAndChildren());516}517}518519export class NewExplorerItem extends ExplorerItem {520constructor(fileService: IFileService, configService: IConfigurationService, filesConfigService: IFilesConfigurationService, parent: ExplorerItem, isDirectory: boolean) {521super(URI.file(''), fileService, configService, filesConfigService, parent, isDirectory);522this._isDirectoryResolved = true;523}524}525526527