Path: blob/main/src/vs/workbench/contrib/files/common/explorerModel.ts
5245 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 {83this._onDidChangeRoots.dispose();84dispose(this._listener);85}86}8788export class ExplorerItem {89_isDirectoryResolved: boolean; // used in tests90public error: Error | undefined = undefined;91private _isExcluded = false;9293public nestedParent: ExplorerItem | undefined;94public nestedChildren: ExplorerItem[] | undefined;9596constructor(97public resource: URI,98private readonly fileService: IFileService,99private readonly configService: IConfigurationService,100private readonly filesConfigService: IFilesConfigurationService,101private _parent: ExplorerItem | undefined,102private _isDirectory?: boolean,103private _isSymbolicLink?: boolean,104private _readonly?: boolean,105private _locked?: boolean,106private _name: string = basenameOrAuthority(resource),107private _mtime?: number,108private _unknown = false109) {110this._isDirectoryResolved = false;111}112113get isExcluded(): boolean {114if (this._isExcluded) {115return true;116}117if (!this._parent) {118return false;119}120121return this._parent.isExcluded;122}123124set isExcluded(value: boolean) {125this._isExcluded = value;126}127128hasChildren(filter: (stat: ExplorerItem) => boolean): boolean {129if (this.hasNests) {130return this.nestedChildren?.some(c => filter(c)) ?? false;131} else {132return this.isDirectory;133}134}135136get hasNests() {137return !!(this.nestedChildren?.length);138}139140get isDirectoryResolved(): boolean {141return this._isDirectoryResolved;142}143144get isSymbolicLink(): boolean {145return !!this._isSymbolicLink;146}147148get isDirectory(): boolean {149return !!this._isDirectory;150}151152get isReadonly(): boolean | IMarkdownString {153return this.filesConfigService.isReadonly(this.resource, { resource: this.resource, name: this.name, readonly: this._readonly, locked: this._locked });154}155156get mtime(): number | undefined {157return this._mtime;158}159160get name(): string {161return this._name;162}163164get isUnknown(): boolean {165return this._unknown;166}167168get parent(): ExplorerItem | undefined {169return this._parent;170}171172get root(): ExplorerItem {173if (!this._parent) {174return this;175}176177return this._parent.root;178}179180@memoize get children(): Map<string, ExplorerItem> {181return new Map<string, ExplorerItem>();182}183184private updateName(value: string): void {185// Re-add to parent since the parent has a name map to children and the name might have changed186this._parent?.removeChild(this);187this._name = value;188this._parent?.addChild(this);189}190191getId(): string {192let id = this.root.resource.toString() + '::' + this.resource.toString();193194if (this.isMarkedAsFiltered()) {195id += '::findFilterResult';196}197198return id;199}200201toString(): string {202return `ExplorerItem: ${this.name}`;203}204205get isRoot(): boolean {206return this === this.root;207}208209static create(fileService: IFileService, configService: IConfigurationService, filesConfigService: IFilesConfigurationService, raw: IFileStat, parent: ExplorerItem | undefined, resolveTo?: readonly URI[]): ExplorerItem {210const 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);211212// Recursively add children if present213if (stat.isDirectory) {214215// isDirectoryResolved is a very important indicator in the stat model that tells if the folder was fully resolved216// the folder is fully resolved if either it has a list of children or the client requested this by using the resolveTo217// array of resource path to resolve.218stat._isDirectoryResolved = !!raw.children || (!!resolveTo && resolveTo.some((r) => {219return isEqualOrParent(r, stat.resource);220}));221222// Recurse into children223if (raw.children) {224for (let i = 0, len = raw.children.length; i < len; i++) {225const child = ExplorerItem.create(fileService, configService, filesConfigService, raw.children[i], stat, resolveTo);226stat.addChild(child);227}228}229}230231return stat;232}233234/**235* Merges the stat which was resolved from the disk with the local stat by copying over properties236* and children. The merge will only consider resolved stat elements to avoid overwriting data which237* exists locally.238*/239static mergeLocalWithDisk(disk: ExplorerItem, local: ExplorerItem): void {240if (disk.resource.toString() !== local.resource.toString()) {241return; // Merging only supported for stats with the same resource242}243244// Stop merging when a folder is not resolved to avoid loosing local data245const mergingDirectories = disk.isDirectory || local.isDirectory;246if (mergingDirectories && local._isDirectoryResolved && !disk._isDirectoryResolved) {247return;248}249250// Properties251local.resource = disk.resource;252if (!local.isRoot) {253local.updateName(disk.name);254}255local._isDirectory = disk.isDirectory;256local._mtime = disk.mtime;257local._isDirectoryResolved = disk._isDirectoryResolved;258local._isSymbolicLink = disk.isSymbolicLink;259local.error = disk.error;260261// Merge Children if resolved262if (mergingDirectories && disk._isDirectoryResolved) {263264// Map resource => stat265const oldLocalChildren = new ResourceMap<ExplorerItem>();266local.children.forEach(child => {267oldLocalChildren.set(child.resource, child);268});269270// Clear current children271local.children.clear();272273// Merge received children274disk.children.forEach(diskChild => {275const formerLocalChild = oldLocalChildren.get(diskChild.resource);276// Existing child: merge277if (formerLocalChild) {278ExplorerItem.mergeLocalWithDisk(diskChild, formerLocalChild);279local.addChild(formerLocalChild);280oldLocalChildren.delete(diskChild.resource);281}282283// New child: add284else {285local.addChild(diskChild);286}287});288289oldLocalChildren.forEach(oldChild => {290if (oldChild instanceof NewExplorerItem) {291local.addChild(oldChild);292}293});294}295}296297/**298* Adds a child element to this folder.299*/300addChild(child: ExplorerItem): void {301// Inherit some parent properties to child302child._parent = this;303child.updateResource(false);304this.children.set(this.getPlatformAwareName(child.name), child);305}306307getChild(name: string): ExplorerItem | undefined {308return this.children.get(this.getPlatformAwareName(name));309}310311fetchChildren(sortOrder: SortOrder): ExplorerItem[] | Promise<ExplorerItem[]> {312const nestingConfig = this.configService.getValue<IFilesConfiguration>({ resource: this.root.resource }).explorer.fileNesting;313314// fast path when the children can be resolved sync315if (nestingConfig.enabled && this.nestedChildren) {316return this.nestedChildren;317}318319return (async () => {320if (!this._isDirectoryResolved) {321// Resolve metadata only when the mtime is needed since this can be expensive322// Mtime is only used when the sort order is 'modified'323const resolveMetadata = sortOrder === SortOrder.Modified;324this.error = undefined;325try {326const stat = await this.fileService.resolve(this.resource, { resolveSingleChildDescendants: true, resolveMetadata });327const resolved = ExplorerItem.create(this.fileService, this.configService, this.filesConfigService, stat, this);328ExplorerItem.mergeLocalWithDisk(resolved, this);329} catch (e) {330this.error = e;331throw e;332}333this._isDirectoryResolved = true;334}335336const items: ExplorerItem[] = [];337if (nestingConfig.enabled) {338const fileChildren: [string, ExplorerItem][] = [];339const dirChildren: [string, ExplorerItem][] = [];340for (const child of this.children.entries()) {341child[1].nestedParent = undefined;342if (child[1].isDirectory) {343dirChildren.push(child);344} else {345fileChildren.push(child);346}347}348349const nested = this.fileNester.nest(350fileChildren.map(([name]) => name),351this.getPlatformAwareName(this.name));352353for (const [fileEntryName, fileEntryItem] of fileChildren) {354const nestedItems = nested.get(fileEntryName);355if (nestedItems !== undefined) {356fileEntryItem.nestedChildren = [];357for (const name of nestedItems.keys()) {358const child = assertReturnsDefined(this.children.get(name));359fileEntryItem.nestedChildren.push(child);360child.nestedParent = fileEntryItem;361}362items.push(fileEntryItem);363} else {364fileEntryItem.nestedChildren = undefined;365}366}367368for (const [_, dirEntryItem] of dirChildren.values()) {369items.push(dirEntryItem);370}371} else {372this.children.forEach(child => {373items.push(child);374});375}376return items;377})();378}379380private _fileNester: ExplorerFileNestingTrie | undefined;381private get fileNester(): ExplorerFileNestingTrie {382if (!this.root._fileNester) {383const nestingConfig = this.configService.getValue<IFilesConfiguration>({ resource: this.root.resource }).explorer.fileNesting;384const patterns = Object.entries(nestingConfig.patterns)385.filter(entry =>386typeof (entry[0]) === 'string' && typeof (entry[1]) === 'string' && entry[0] && entry[1])387.map(([parentPattern, childrenPatterns]) =>388[389this.getPlatformAwareName(parentPattern.trim()),390childrenPatterns.split(',').map(p => this.getPlatformAwareName(p.trim().replace(/\u200b/g, '').trim()))391.filter(p => p !== '')392] as [string, string[]]);393394this.root._fileNester = new ExplorerFileNestingTrie(patterns);395}396return this.root._fileNester;397}398399/**400* Removes a child element from this folder.401*/402removeChild(child: ExplorerItem): void {403this.nestedChildren = undefined;404this.children.delete(this.getPlatformAwareName(child.name));405}406407forgetChildren(): void {408this.children.clear();409this.nestedChildren = undefined;410this._isDirectoryResolved = false;411this._fileNester = undefined;412}413414private getPlatformAwareName(name: string): string {415return this.fileService.hasCapability(this.resource, FileSystemProviderCapabilities.PathCaseSensitive) ? name : name.toLowerCase();416}417418/**419* Moves this element under a new parent element.420*/421move(newParent: ExplorerItem): void {422this.nestedParent?.removeChild(this);423this._parent?.removeChild(this);424newParent.removeChild(this); // make sure to remove any previous version of the file if any425newParent.addChild(this);426this.updateResource(true);427}428429private updateResource(recursive: boolean): void {430if (this._parent) {431this.resource = joinPath(this._parent.resource, this.name);432}433434if (recursive) {435if (this.isDirectory) {436this.children.forEach(child => {437child.updateResource(true);438});439}440}441}442443/**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*/447rename(renamedStat: { name: string; mtime?: number }): void {448449// Merge a subset of Properties that can change on rename450this.updateName(renamedStat.name);451this._mtime = renamedStat.mtime;452453// Update Paths including children454this.updateResource(true);455}456457/**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*/461find(resource: URI): ExplorerItem | null {462// Return if path found463// For performance reasons try to do the comparison as fast as possible464const ignoreCase = !this.fileService.hasCapability(resource, FileSystemProviderCapabilities.PathCaseSensitive);465if (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))) {467return this.findByPath(rtrim(resource.path, posix.sep), this.resource.path.length, ignoreCase);468}469470return null; //Unable to find471}472473private findByPath(path: string, index: number, ignoreCase: boolean): ExplorerItem | null {474if (isEqual(rtrim(this.resource.path, posix.sep), path, ignoreCase)) {475return this;476}477478if (this.isDirectory) {479// Ignore separtor to more easily deduct the next name to search480while (index < path.length && path[index] === posix.sep) {481index++;482}483484let indexOfNextSep = path.indexOf(posix.sep, index);485if (indexOfNextSep === -1) {486// If there is no separator take the remainder of the path487indexOfNextSep = path.length;488}489// The name to search is between two separators490const name = path.substring(index, indexOfNextSep);491492const child = this.children.get(this.getPlatformAwareName(name));493494if (child) {495// We found a child with the given name, search inside it496return child.findByPath(path, indexOfNextSep, ignoreCase);497}498}499500return null;501}502503// Find504private markedAsFindResult = false;505isMarkedAsFiltered(): boolean {506return this.markedAsFindResult;507}508509markItemAndParentsAsFiltered(): void {510this.markedAsFindResult = true;511this.parent?.markItemAndParentsAsFiltered();512}513514unmarkItemAndChildren(): void {515this.markedAsFindResult = false;516this.children.forEach(child => child.unmarkItemAndChildren());517}518}519520export class NewExplorerItem extends ExplorerItem {521constructor(fileService: IFileService, configService: IConfigurationService, filesConfigService: IFilesConfigurationService, parent: ExplorerItem, isDirectory: boolean) {522super(URI.file(''), fileService, configService, filesConfigService, parent, isDirectory);523this._isDirectoryResolved = true;524}525}526527528