✨ feat(vscode): 代码阅读侧栏给目录、文件记注释

This commit is contained in:
cruldra committed 2026-09-25 19:21:03 +08:00
1 parent beabffdf2c
commit 05c4bec56b
14 files changed
+703 -23

No files matched your search

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { createExcludeMatcher, sortEntries } from './fileEntries'
import { createExcludeMatcher, entryContext, sortEntries } from './fileEntries'
const dir = (name: string): { name: string, isDirectory: boolean } => ({ name, isDirectory: true })
const file = (name: string): { name: string, isDirectory: boolean } => ({ name, isDirectory: false })
@@ -22,6 +22,14 @@ describe('文件树排序', () => {
})
})
describe('行的 contextValue', () => {
it('工作区根、目录、文件各一种,菜单按它挂「添加注释」', () => {
expect(entryContext({ isDirectory: true, isRoot: true })).toBe('root')
expect(entryContext({ isDirectory: true, isRoot: false })).toBe('folder')
expect(entryContext({ isDirectory: false, isRoot: false })).toBe('file')
})
})
describe('files.exclude', () => {
const none = new Set<string>()
@@ -12,6 +12,11 @@ export function sortEntries<T extends DirEntry>(entries: T[]): T[] {
|| (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
}
/** 树上一行的 contextValue:菜单的 when 子句按它分工作区根、目录、文件。 */
export function entryContext(entry: { isDirectory: boolean, isRoot: boolean }): 'root' | 'folder' | 'file' {
return entry.isRoot ? 'root' : entry.isDirectory ? 'folder' : 'file'
}
/** `files.exclude` 的值:true 排除;false 不排除;`{ when }` 只在有对应的兄弟文件时排除。 */
export type ExcludeSetting = Record<string, boolean | { when: string } | null | undefined>
+56 -18
View File
@@ -2,7 +2,7 @@ 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'
import { createExcludeMatcher, entryContext, sortEntries } from './fileEntries'
export interface FileNode {
uri: Uri
@@ -11,6 +11,12 @@ export interface FileNode {
isRoot: boolean
}
/** 给树上的行添信息(比如文件注释):每次画行时调 decorate,onDidChange 报出哪些 Uri 的行要重画。 */
export interface FileTreeDecorator {
readonly onDidChange: Event<readonly Uri[]>
decorate: (item: TreeItem, node: FileNode) => void
}
/** 批量改文件(git checkout、构建输出)时合并成一次刷新。 */
const REFRESH_DELAY_MS = 300
@@ -19,13 +25,19 @@ const REFRESH_DELAY_MS = 300
* 节点用 resourceUri,图标、git 改动颜色都由 VS Code 按文件图标主题和 SCM 装饰自动加上。
*/
export class FileTreeProvider implements TreeDataProvider<FileNode> {
private readonly changed = new EventEmitter<void>()
readonly onDidChangeTreeData: Event<void> = this.changed.event
/** undefined 是整棵树重画,数组是只重画这几行 */
private readonly changed = new EventEmitter<FileNode[] | undefined>()
readonly onDidChangeTreeData: Event<FileNode[] | undefined> = this.changed.event
private readonly disposables: { dispose: () => unknown }[]
/** 按 Uri 记下交给 VS Code 的节点:只重画几行时要交回同一个对象,VS Code 按对象认行。 */
private readonly nodes = new Map<string, FileNode>()
private timer: NodeJS.Timeout | undefined
/** @param openCommand 点文件时执行的命令,参数是文件 Uri */
constructor(private readonly openCommand: string) {
/**
* @param openCommand 点文件时执行的命令,参数是文件 Uri
* @param decorator 给行添信息(文件注释)
*/
constructor(private readonly openCommand: string, private readonly decorator: FileTreeDecorator) {
// 只关心增删:文件内容变化不影响树的结构
const watcher = workspace.createFileSystemWatcher('**/*', false, true, false)
this.disposables = [
@@ -38,32 +50,27 @@ export class FileTreeProvider implements TreeDataProvider<FileNode> {
if (e.affectsConfiguration('files.exclude'))
this.refresh()
}),
decorator.onDidChange(uris => this.redraw(uris)),
]
}
refresh(): void {
clearTimeout(this.timer)
this.timer = undefined
this.changed.fire()
this.nodes.clear()
this.changed.fire(undefined)
}
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
const item = this.baseItem(node)
item.contextValue = entryContext(node)
this.decorator.decorate(item, node)
return item
}
async getChildren(node?: FileNode): Promise<FileNode[]> {
if (!node)
return (workspace.workspaceFolders ?? []).map(folder => ({ uri: folder.uri, folder, isDirectory: true, isRoot: true }))
return (workspace.workspaceFolders ?? []).map(folder => this.nodeAt(folder.uri, folder, true, true))
let entries: [string, FileType][]
try {
@@ -77,7 +84,7 @@ export class FileTreeProvider implements TreeDataProvider<FileNode> {
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 }))
.map(entry => this.nodeAt(Uri.joinPath(node.uri, entry.name), node.folder, entry.isDirectory, false))
}
dispose(): void {
@@ -86,6 +93,37 @@ export class FileTreeProvider implements TreeDataProvider<FileNode> {
disposable.dispose()
}
private baseItem(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
}
private nodeAt(uri: Uri, folder: WorkspaceFolder, isDirectory: boolean, isRoot: boolean): FileNode {
const id = uri.toString()
const known = this.nodes.get(id)
if (known && known.folder === folder && known.isDirectory === isDirectory && known.isRoot === isRoot)
return known
const node: FileNode = { uri, folder, isDirectory, isRoot }
this.nodes.set(id, node)
return node
}
/** 还没展开过的行不用管,展开时自然按最新的画。 */
private redraw(uris: readonly Uri[]): void {
const nodes = uris.flatMap(uri => this.nodes.get(uri.toString()) ?? [])
if (nodes.length > 0)
this.changed.fire(nodes)
}
private scheduleRefresh(): void {
clearTimeout(this.timer)
this.timer = setTimeout(() => this.refresh(), REFRESH_DELAY_MS)