import type { Event, TreeDataProvider, WorkspaceFolder } from 'vscode' import type { ExcludeSetting } from './fileEntries' import * as path from 'node:path' import { EventEmitter, FileType, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri, workspace } from 'vscode' import { createExcludeMatcher, sortEntries } from './fileEntries' export interface FileNode { uri: Uri folder: WorkspaceFolder isDirectory: boolean isRoot: boolean } /** 批量改文件(git checkout、构建输出)时合并成一次刷新。 */ const REFRESH_DELAY_MS = 300 /** * 侧栏的工作区文件树:每个工作区文件夹一个根节点,只看和打开。 * 节点用 resourceUri,图标、git 改动颜色都由 VS Code 按文件图标主题和 SCM 装饰自动加上。 */ export class FileTreeProvider implements TreeDataProvider { private readonly changed = new EventEmitter() readonly onDidChangeTreeData: Event = this.changed.event private readonly disposables: { dispose: () => unknown }[] private timer: NodeJS.Timeout | undefined /** @param openCommand 点文件时执行的命令,参数是文件 Uri */ constructor(private readonly openCommand: string) { // 只关心增删:文件内容变化不影响树的结构 const watcher = workspace.createFileSystemWatcher('**/*', false, true, false) this.disposables = [ this.changed, watcher, watcher.onDidCreate(() => this.scheduleRefresh()), watcher.onDidDelete(() => this.scheduleRefresh()), workspace.onDidChangeWorkspaceFolders(() => this.refresh()), workspace.onDidChangeConfiguration((e) => { if (e.affectsConfiguration('files.exclude')) this.refresh() }), ] } refresh(): void { clearTimeout(this.timer) this.timer = undefined this.changed.fire() } getTreeItem(node: FileNode): TreeItem { if (!node.isDirectory) { const item = new TreeItem(node.uri, TreeItemCollapsibleState.None) item.iconPath = ThemeIcon.File item.command = { command: this.openCommand, title: '打开', arguments: [node.uri] } return item } const item = new TreeItem(node.uri, node.isRoot ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed) item.iconPath = ThemeIcon.Folder if (node.isRoot) item.label = node.folder.name return item } async getChildren(node?: FileNode): Promise { if (!node) return (workspace.workspaceFolders ?? []).map(folder => ({ uri: folder.uri, folder, isDirectory: true, isRoot: true })) let entries: [string, FileType][] try { entries = await workspace.fs.readDirectory(node.uri) } catch { return [] // 目录刚被删掉或没有权限 } const excluded = createExcludeMatcher(workspace.getConfiguration('files', node.folder).get('exclude')) const names = new Set(entries.map(([name]) => name)) const dir = path.posix.relative(node.folder.uri.path, node.uri.path) return sortEntries(entries.map(([name, type]) => ({ name, isDirectory: (type & FileType.Directory) !== 0 }))) .filter(entry => !excluded(dir ? `${dir}/${entry.name}` : entry.name, names)) .map(entry => ({ uri: Uri.joinPath(node.uri, entry.name), folder: node.folder, isDirectory: entry.isDirectory, isRoot: false })) } dispose(): void { clearTimeout(this.timer) for (const disposable of this.disposables) disposable.dispose() } private scheduleRefresh(): void { clearTimeout(this.timer) this.timer = setTimeout(() => this.refresh(), REFRESH_DELAY_MS) } }