Path: blob/main/src/vs/workbench/contrib/files/browser/explorerService.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 { Event } from '../../../../base/common/event.js';6import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';7import { DisposableStore } from '../../../../base/common/lifecycle.js';8import { IFilesConfiguration, ISortOrderConfiguration, SortOrder, LexicographicOptions } from '../common/files.js';9import { ExplorerItem, ExplorerModel } from '../common/explorerModel.js';10import { URI } from '../../../../base/common/uri.js';11import { FileOperationEvent, FileOperation, IFileService, FileChangesEvent, FileChangeType, IResolveFileOptions } from '../../../../platform/files/common/files.js';12import { dirname, basename } from '../../../../base/common/resources.js';13import { IConfigurationService, IConfigurationChangeEvent } from '../../../../platform/configuration/common/configuration.js';14import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js';15import { IEditorService } from '../../../services/editor/common/editorService.js';16import { IEditableData } from '../../../common/views.js';17import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js';18import { IBulkEditService, ResourceFileEdit } from '../../../../editor/browser/services/bulkEditService.js';19import { UndoRedoSource } from '../../../../platform/undoRedo/common/undoRedo.js';20import { IExplorerView, IExplorerService } from './files.js';21import { IProgressService, ProgressLocation, IProgressCompositeOptions, IProgressOptions } from '../../../../platform/progress/common/progress.js';22import { CancellationTokenSource } from '../../../../base/common/cancellation.js';23import { RunOnceScheduler } from '../../../../base/common/async.js';24import { IHostService } from '../../../services/host/browser/host.js';25import { IExpression } from '../../../../base/common/glob.js';26import { ResourceGlobMatcher } from '../../../common/resources.js';27import { IFilesConfigurationService } from '../../../services/filesConfiguration/common/filesConfigurationService.js';2829export const UNDO_REDO_SOURCE = new UndoRedoSource();3031export class ExplorerService implements IExplorerService {32declare readonly _serviceBrand: undefined;3334private 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 first3536private readonly disposables = new DisposableStore();37private editable: { stat: ExplorerItem; data: IEditableData } | undefined;38private config: IFilesConfiguration['explorer'];39private cutItems: ExplorerItem[] | undefined;40private view: IExplorerView | undefined;41private model: ExplorerModel;42private onFileChangesScheduler: RunOnceScheduler;43private fileChangeEvents: FileChangesEvent[] = [];44private revealExcludeMatcher: ResourceGlobMatcher;4546constructor(47@IFileService private fileService: IFileService,48@IConfigurationService private configurationService: IConfigurationService,49@IWorkspaceContextService private contextService: IWorkspaceContextService,50@IClipboardService private clipboardService: IClipboardService,51@IEditorService private editorService: IEditorService,52@IUriIdentityService private readonly uriIdentityService: IUriIdentityService,53@IBulkEditService private readonly bulkEditService: IBulkEditService,54@IProgressService private readonly progressService: IProgressService,55@IHostService hostService: IHostService,56@IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService57) {58this.config = this.configurationService.getValue('explorer');5960this.model = new ExplorerModel(this.contextService, this.uriIdentityService, this.fileService, this.configurationService, this.filesConfigurationService);61this.disposables.add(this.model);62this.disposables.add(this.fileService.onDidRunOperation(e => this.onDidRunOperation(e)));6364this.onFileChangesScheduler = new RunOnceScheduler(async () => {65const events = this.fileChangeEvents;66this.fileChangeEvents = [];6768// Filter to the ones we care69const types = [FileChangeType.DELETED];70if (this.config.sortOrder === SortOrder.Modified) {71types.push(FileChangeType.UPDATED);72}7374let shouldRefresh = false;75// For DELETED and UPDATED events go through the explorer model and check if any of the items got affected76this.roots.forEach(r => {77if (this.view && !shouldRefresh) {78shouldRefresh = doesFileEventAffect(r, this.view, events, types);79}80});81// For ADDED events we need to go through all the events and check if the explorer is already aware of some of them82// Or if they affect not yet resolved parts of the explorer. If that is the case we will not refresh.83events.forEach(e => {84if (!shouldRefresh) {85for (const resource of e.rawAdded) {86const parent = this.model.findClosest(dirname(resource));87// Parent of the added resource is resolved and the explorer model is not aware of the added resource - we need to refresh88if (parent && !parent.getChild(basename(resource))) {89shouldRefresh = true;90break;91}92}93}94});9596if (shouldRefresh) {97await this.refresh(false);98}99100}, ExplorerService.EXPLORER_FILE_CHANGES_REACT_DELAY);101102this.disposables.add(this.fileService.onDidFilesChange(e => {103this.fileChangeEvents.push(e);104// Don't mess with the file tree while in the process of editing. #112293105if (this.editable) {106return;107}108if (!this.onFileChangesScheduler.isScheduled()) {109this.onFileChangesScheduler.schedule();110}111}));112this.disposables.add(this.configurationService.onDidChangeConfiguration(e => this.onConfigurationUpdated(e)));113this.disposables.add(Event.any<{ scheme: string }>(this.fileService.onDidChangeFileSystemProviderRegistrations, this.fileService.onDidChangeFileSystemProviderCapabilities)(async e => {114let affected = false;115this.model.roots.forEach(r => {116if (r.resource.scheme === e.scheme) {117affected = true;118r.forgetChildren();119}120});121if (affected) {122if (this.view) {123await this.view.setTreeInput();124}125}126}));127this.disposables.add(this.model.onDidChangeRoots(() => {128this.view?.setTreeInput();129}));130131// Refresh explorer when window gets focus to compensate for missing file events #126817132this.disposables.add(hostService.onDidChangeFocus(hasFocus => {133if (hasFocus) {134this.refresh(false);135}136}));137this.revealExcludeMatcher = new ResourceGlobMatcher(138(uri) => getRevealExcludes(configurationService.getValue<IFilesConfiguration>({ resource: uri })),139(event) => event.affectsConfiguration('explorer.autoRevealExclude'),140contextService, configurationService);141this.disposables.add(this.revealExcludeMatcher);142}143144get roots(): ExplorerItem[] {145return this.model.roots;146}147148get sortOrderConfiguration(): ISortOrderConfiguration {149return {150sortOrder: this.config.sortOrder,151lexicographicOptions: this.config.sortOrderLexicographicOptions,152reverse: this.config.sortOrderReverse,153};154}155156registerView(contextProvider: IExplorerView): void {157this.view = contextProvider;158}159160getContext(respectMultiSelection: boolean, ignoreNestedChildren: boolean = false): ExplorerItem[] {161if (!this.view) {162return [];163}164165const items = new Set<ExplorerItem>(this.view.getContext(respectMultiSelection));166items.forEach(item => {167try {168if (respectMultiSelection && !ignoreNestedChildren && this.view?.isItemCollapsed(item) && item.nestedChildren) {169for (const child of item.nestedChildren) {170items.add(child);171}172}173} catch {174// We will error out trying to resolve collapsed nodes that have not yet been resolved.175// So we catch and ignore them in the multiSelect context176return;177}178});179180return [...items];181}182183async applyBulkEdit(edit: ResourceFileEdit[], options: { undoLabel: string; progressLabel: string; confirmBeforeUndo?: boolean; progressLocation?: ProgressLocation.Explorer | ProgressLocation.Window }): Promise<void> {184const cancellationTokenSource = new CancellationTokenSource();185const location = options.progressLocation ?? ProgressLocation.Window;186let progressOptions;187if (location === ProgressLocation.Window) {188progressOptions = {189location: location,190title: options.progressLabel,191cancellable: edit.length > 1,192} satisfies IProgressOptions;193} else {194progressOptions = {195location: location,196title: options.progressLabel,197cancellable: edit.length > 1,198delay: 500,199} satisfies IProgressCompositeOptions;200}201const promise = this.progressService.withProgress(progressOptions, async progress => {202await this.bulkEditService.apply(edit, {203undoRedoSource: UNDO_REDO_SOURCE,204label: options.undoLabel,205code: 'undoredo.explorerOperation',206progress,207token: cancellationTokenSource.token,208confirmBeforeUndo: options.confirmBeforeUndo209});210}, () => cancellationTokenSource.cancel());211await this.progressService.withProgress({ location: ProgressLocation.Explorer, delay: 500 }, () => promise);212cancellationTokenSource.dispose();213}214215hasViewFocus(): boolean {216return !!this.view && this.view.hasFocus();217}218219// IExplorerService methods220221findClosest(resource: URI): ExplorerItem | null {222return this.model.findClosest(resource);223}224225findClosestRoot(resource: URI): ExplorerItem | null {226const parentRoots = this.model.roots.filter(r => this.uriIdentityService.extUri.isEqualOrParent(resource, r.resource))227.sort((first, second) => second.resource.path.length - first.resource.path.length);228return parentRoots.length ? parentRoots[0] : null;229}230231async setEditable(stat: ExplorerItem, data: IEditableData | null): Promise<void> {232if (!this.view) {233return;234}235236if (!data) {237this.editable = undefined;238} else {239this.editable = { stat, data };240}241const isEditing = this.isEditable(stat);242try {243await this.view.setEditable(stat, isEditing);244} catch {245return;246}247248249if (!this.editable && this.fileChangeEvents.length && !this.onFileChangesScheduler.isScheduled()) {250this.onFileChangesScheduler.schedule();251}252}253254async setToCopy(items: ExplorerItem[], cut: boolean): Promise<void> {255const previouslyCutItems = this.cutItems;256this.cutItems = cut ? items : undefined;257await this.clipboardService.writeResources(items.map(s => s.resource));258259this.view?.itemsCopied(items, cut, previouslyCutItems);260}261262isCut(item: ExplorerItem): boolean {263return !!this.cutItems && this.cutItems.some(i => this.uriIdentityService.extUri.isEqual(i.resource, item.resource));264}265266getEditable(): { stat: ExplorerItem; data: IEditableData } | undefined {267return this.editable;268}269270getEditableData(stat: ExplorerItem): IEditableData | undefined {271return this.editable && this.editable.stat === stat ? this.editable.data : undefined;272}273274isEditable(stat: ExplorerItem | undefined): boolean {275return !!this.editable && (this.editable.stat === stat || !stat);276}277278async select(resource: URI, reveal?: boolean | string): Promise<void> {279if (!this.view) {280return;281}282283// If file or parent matches exclude patterns, do not reveal unless reveal argument is 'force'284const ignoreRevealExcludes = reveal === 'force';285286const fileStat = this.findClosest(resource);287if (fileStat) {288if (!this.shouldAutoRevealItem(fileStat, ignoreRevealExcludes)) {289return;290}291await this.view.selectResource(fileStat.resource, reveal);292return Promise.resolve(undefined);293}294295// Stat needs to be resolved first and then revealed296const options: IResolveFileOptions = { resolveTo: [resource], resolveMetadata: this.config.sortOrder === SortOrder.Modified };297const root = this.findClosestRoot(resource);298if (!root) {299return undefined;300}301302try {303const stat = await this.fileService.resolve(root.resource, options);304305// Convert to model306const modelStat = ExplorerItem.create(this.fileService, this.configurationService, this.filesConfigurationService, stat, undefined, options.resolveTo);307// Update Input with disk Stat308ExplorerItem.mergeLocalWithDisk(modelStat, root);309const item = root.find(resource);310await this.view.refresh(true, root);311312// Once item is resolved, check again if folder should be expanded313if (item && !this.shouldAutoRevealItem(item, ignoreRevealExcludes)) {314return;315}316await this.view.selectResource(item ? item.resource : undefined, reveal);317} catch (error) {318root.error = error;319await this.view.refresh(false, root);320}321}322323async refresh(reveal = true): Promise<void> {324// Do not refresh the tree when it is showing temporary nodes (phantom elements)325if (this.view?.hasPhantomElements()) {326return;327}328329this.model.roots.forEach(r => r.forgetChildren());330if (this.view) {331await this.view.refresh(true);332const resource = this.editorService.activeEditor?.resource;333const autoReveal = this.configurationService.getValue<IFilesConfiguration>().explorer.autoReveal;334335if (reveal && resource && autoReveal) {336// We did a top level refresh, reveal the active file #67118337this.select(resource, autoReveal);338}339}340}341342// File events343344private async onDidRunOperation(e: FileOperationEvent): Promise<void> {345// When nesting, changes to one file in a folder may impact the rendered structure346// of all the folder's immediate children, thus a recursive refresh is needed.347// Ideally the tree would be able to recusively refresh just one level but that does not yet exist.348const shouldDeepRefresh = this.config.fileNesting.enabled;349350// Add351if (e.isOperation(FileOperation.CREATE) || e.isOperation(FileOperation.COPY)) {352const addedElement = e.target;353const parentResource = dirname(addedElement.resource)!;354const parents = this.model.findAll(parentResource);355356if (parents.length) {357358// Add the new file to its parent (Model)359await Promise.all(parents.map(async p => {360// We have to check if the parent is resolved #29177361const resolveMetadata = this.config.sortOrder === `modified`;362if (!p.isDirectoryResolved) {363const stat = await this.fileService.resolve(p.resource, { resolveMetadata });364if (stat) {365const modelStat = ExplorerItem.create(this.fileService, this.configurationService, this.filesConfigurationService, stat, p.parent);366ExplorerItem.mergeLocalWithDisk(modelStat, p);367}368}369370const childElement = ExplorerItem.create(this.fileService, this.configurationService, this.filesConfigurationService, addedElement, p.parent);371// Make sure to remove any previous version of the file if any372p.removeChild(childElement);373p.addChild(childElement);374// Refresh the Parent (View)375await this.view?.refresh(shouldDeepRefresh, p);376}));377}378}379380// Move (including Rename)381else if (e.isOperation(FileOperation.MOVE)) {382const oldResource = e.resource;383const newElement = e.target;384const oldParentResource = dirname(oldResource);385const newParentResource = dirname(newElement.resource);386const modelElements = this.model.findAll(oldResource);387const sameParentMove = modelElements.every(e => !e.nestedParent) && this.uriIdentityService.extUri.isEqual(oldParentResource, newParentResource);388389// Handle Rename390if (sameParentMove) {391await Promise.all(modelElements.map(async modelElement => {392// Rename File (Model)393modelElement.rename(newElement);394await this.view?.refresh(shouldDeepRefresh, modelElement.parent);395}));396}397398// Handle Move399else {400const newParents = this.model.findAll(newParentResource);401if (newParents.length && modelElements.length) {402// Move in Model403await Promise.all(modelElements.map(async (modelElement, index) => {404const oldParent = modelElement.parent;405const oldNestedParent = modelElement.nestedParent;406modelElement.move(newParents[index]);407if (oldNestedParent) {408await this.view?.refresh(false, oldNestedParent);409}410await this.view?.refresh(false, oldParent);411await this.view?.refresh(shouldDeepRefresh, newParents[index]);412}));413}414}415}416417// Delete418else if (e.isOperation(FileOperation.DELETE)) {419const modelElements = this.model.findAll(e.resource);420await Promise.all(modelElements.map(async modelElement => {421if (modelElement.parent) {422// Remove Element from Parent (Model)423const parent = modelElement.parent;424parent.removeChild(modelElement);425this.view?.focusNext();426427const oldNestedParent = modelElement.nestedParent;428if (oldNestedParent) {429oldNestedParent.removeChild(modelElement);430await this.view?.refresh(false, oldNestedParent);431}432// Refresh Parent (View)433await this.view?.refresh(shouldDeepRefresh, parent);434435if (this.view?.getFocus().length === 0) {436this.view?.focusLast();437}438}439}));440}441}442443// Check if an item matches a explorer.autoRevealExclude pattern444private shouldAutoRevealItem(item: ExplorerItem | undefined, ignore: boolean): boolean {445if (item === undefined || ignore) {446return true;447}448if (this.revealExcludeMatcher.matches(item.resource, name => !!(item.parent && item.parent.getChild(name)))) {449return false;450}451const root = item.root;452let currentItem = item.parent;453while (currentItem !== root) {454if (currentItem === undefined) {455return true;456}457if (this.revealExcludeMatcher.matches(currentItem.resource)) {458return false;459}460currentItem = currentItem.parent;461}462return true;463}464465private async onConfigurationUpdated(event: IConfigurationChangeEvent): Promise<void> {466if (!event.affectsConfiguration('explorer')) {467return;468}469470let shouldRefresh = false;471472if (event.affectsConfiguration('explorer.fileNesting')) {473shouldRefresh = true;474}475476const configuration = this.configurationService.getValue<IFilesConfiguration>();477478const configSortOrder = configuration?.explorer?.sortOrder || SortOrder.Default;479if (this.config.sortOrder !== configSortOrder) {480shouldRefresh = this.config.sortOrder !== undefined;481}482483const configLexicographicOptions = configuration?.explorer?.sortOrderLexicographicOptions || LexicographicOptions.Default;484if (this.config.sortOrderLexicographicOptions !== configLexicographicOptions) {485shouldRefresh = shouldRefresh || this.config.sortOrderLexicographicOptions !== undefined;486}487const sortOrderReverse = configuration?.explorer?.sortOrderReverse || false;488489if (this.config.sortOrderReverse !== sortOrderReverse) {490shouldRefresh = shouldRefresh || this.config.sortOrderReverse !== undefined;491}492493this.config = configuration.explorer;494495if (shouldRefresh) {496await this.refresh();497}498}499500dispose(): void {501this.disposables.dispose();502}503}504505function doesFileEventAffect(item: ExplorerItem, view: IExplorerView, events: FileChangesEvent[], types: FileChangeType[]): boolean {506for (const [_name, child] of item.children) {507if (view.isItemVisible(child)) {508if (events.some(e => e.contains(child.resource, ...types))) {509return true;510}511if (child.isDirectory && child.isDirectoryResolved) {512if (doesFileEventAffect(child, view, events, types)) {513return true;514}515}516}517}518519return false;520}521522function getRevealExcludes(configuration: IFilesConfiguration): IExpression {523const revealExcludes = configuration && configuration.explorer && configuration.explorer.autoRevealExclude;524525if (!revealExcludes) {526return {};527}528529return revealExcludes;530}531532533