✨ feat(vscode): 代码阅读入口、文件树与预览编辑器
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user