✨ feat(vscode): 代码阅读侧栏给目录、文件记注释
This commit is contained in:
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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user