✨ feat(vscode): 代码阅读入口、文件树与预览编辑器

This commit is contained in:
cruldra committed 2026-09-24 21:36:00 +08:00
1 parent 4d34b8c715
commit 2b823a32ca
24 files changed
+768 -72

No files matched your search

@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { createExcludeMatcher, 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 })
describe('文件树排序', () => {
it('目录在前、文件在后,名字不区分大小写', () => {
const sorted = sortEntries([file('b.ts'), dir('src'), file('A.ts'), dir('Docs'), file('a.md')])
expect(sorted.map(e => e.name)).toEqual(['Docs', 'src', 'a.md', 'A.ts', 'b.ts'])
})
it('名字里的数字按数值比', () => {
expect(sortEntries([file('item10.ts'), file('item2.ts'), file('item1.ts')]).map(e => e.name))
.toEqual(['item1.ts', 'item2.ts', 'item10.ts'])
})
it('只差大小写的名字次序稳定,不依赖输入顺序', () => {
const a = sortEntries([file('readme.md'), file('README.md')]).map(e => e.name)
const b = sortEntries([file('README.md'), file('readme.md')]).map(e => e.name)
expect(a).toEqual(b)
})
})
describe('files.exclude', () => {
const none = new Set<string>()
it('默认的排除项(.git、.DS_Store 等)在任意层级都生效', () => {
const excluded = createExcludeMatcher({ '**/.git': true, '**/.DS_Store': true, '**/node_modules': true })
expect(excluded('.git', none)).toBe(true)
expect(excluded('packages/app/.DS_Store', none)).toBe(true)
expect(excluded('node_modules', none)).toBe(true)
expect(excluded('src/.gitignore', none)).toBe(false)
expect(excluded('src/index.ts', none)).toBe(false)
})
it('值为 false 的规则不排除;没有配置时什么都不排除', () => {
expect(createExcludeMatcher({ '**/dist': false })('dist', none)).toBe(false)
expect(createExcludeMatcher(undefined)('dist', none)).toBe(false)
})
it('不带 **/ 的规则只匹配工作区根下的路径', () => {
const excluded = createExcludeMatcher({ build: true })
expect(excluded('build', none)).toBe(true)
expect(excluded('src/build', none)).toBe(false)
})
it('* 也匹配点开头的名字,花括号展开可用', () => {
const excluded = createExcludeMatcher({ '**/*.{log,tmp}': true })
expect(excluded('.debug.log', none)).toBe(true)
expect(excluded('.github/x.tmp', none)).toBe(true)
expect(excluded('x.ts', none)).toBe(false)
})
it('when 条件:有同名的兄弟文件才排除', () => {
const excluded = createExcludeMatcher({ '**/*.js': { when: '$(basename).ts' } })
expect(excluded('src/app.js', new Set(['app.js', 'app.ts']))).toBe(true)
expect(excluded('src/tool.js', new Set(['tool.js', 'app.ts']))).toBe(false)
})
})
@@ -0,0 +1,43 @@
import { Minimatch } from 'minimatch'
export interface DirEntry { name: string, isDirectory: boolean }
const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true })
/** 目录在前、文件在后;同类按名字不区分大小写排(数字按数值比,和自带资源管理器一致),只差大小写时再按原样比,保证次序稳定。 */
export function sortEntries<T extends DirEntry>(entries: T[]): T[] {
return [...entries].sort((a, b) =>
Number(b.isDirectory) - Number(a.isDirectory)
|| collator.compare(a.name, b.name)
|| (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
}
/** `files.exclude` 的值:true 排除;false 不排除;`{ when }` 只在有对应的兄弟文件时排除。 */
export type ExcludeSetting = Record<string, boolean | { when: string } | null | undefined>
/**
* @param relativePath 相对工作区文件夹的路径,用 `/` 分隔
* @param siblingNames 同一目录下的全部名字(算 `when` 条件用)
*/
export type ExcludeMatcher = (relativePath: string, siblingNames: ReadonlySet<string>) => boolean
/** 去掉最后一个扩展名:`when` 里的 `$(basename)` 指的就是它。 */
function stem(name: string): string {
const dot = name.lastIndexOf('.')
return dot > 0 ? name.slice(0, dot) : name
}
/** 按 `files.exclude` 的写法判断路径是否被排除:glob 相对工作区文件夹匹配,`*` 也匹配点开头的名字。 */
export function createExcludeMatcher(setting: ExcludeSetting | undefined): ExcludeMatcher {
const rules = Object.entries(setting ?? {}).flatMap(([pattern, value]) => value
? [{ glob: new Minimatch(pattern, { dot: true }), when: typeof value === 'object' ? value.when : undefined }]
: [])
return (relativePath, siblingNames) => rules.some(({ glob, when }) => {
if (!glob.match(relativePath))
return false
if (when === undefined)
return true
const name = relativePath.slice(relativePath.lastIndexOf('/') + 1)
return siblingNames.has(when.replace('$(basename)', stem(name)))
})
}
@@ -0,0 +1,93 @@
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<FileNode> {
private readonly changed = new EventEmitter<void>()
readonly onDidChangeTreeData: Event<void> = 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<FileNode[]> {
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<ExcludeSetting>('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)
}
}