✨ 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)
|
||||
|
||||
@@ -6,6 +6,8 @@ import { showPreview, showSource } from './editor/switchEditor'
|
||||
import { FileTreeProvider } from './explorer/fileTree'
|
||||
import { createPythonAdapter } from './languages/python'
|
||||
import { createTypeScriptAdapter } from './languages/typescript'
|
||||
import { registerNoteCommands } from './notes/commands'
|
||||
import { FileNotes } from './notes/fileNotes'
|
||||
import { CodeReaderRegistry } from './registry'
|
||||
import { bundledWasmDir, TreeSitterRuntime } from './runtime'
|
||||
import { pythonZhTranslator } from './translators/pythonZh'
|
||||
@@ -37,16 +39,19 @@ async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 接入代码阅读:注册表、侧栏文件树、预览编辑器、编辑器右上角的「预览 / 编辑」切换。
|
||||
* 接入代码阅读:注册表、侧栏文件树和文件注释、预览编辑器、编辑器右上角的「预览 / 编辑」切换。
|
||||
* 内置的语言插件和翻译插件与其他扩展一样通过返回的 API 注册。
|
||||
*/
|
||||
export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
const registry = new CodeReaderRegistry(context.globalState)
|
||||
const runtime = new TreeSitterRuntime(bundledWasmDir())
|
||||
const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime)
|
||||
const files = new FileTreeProvider(OPEN_COMMAND)
|
||||
const notes = new FileNotes()
|
||||
const files = new FileTreeProvider(OPEN_COMMAND, notes)
|
||||
|
||||
context.subscriptions.push(
|
||||
notes,
|
||||
registerNoteCommands(notes),
|
||||
registry.onDidChange(() => {
|
||||
void commands.executeCommand('setContext', LANGUAGE_IDS_CONTEXT, registry.languageIds())
|
||||
previews.refresh()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { FileNode } from '../explorer/fileTree'
|
||||
import type { FileNotes } from './fileNotes'
|
||||
import * as path from 'node:path'
|
||||
import { commands, Disposable, window } from 'vscode'
|
||||
|
||||
/** 没注释的行显示「添加注释」,有注释的显示「编辑注释」(package.json 里按 contextValue 切);做的事一样。 */
|
||||
const ADD_COMMAND = 'superpowers.codeReader.addNote'
|
||||
const EDIT_COMMAND = 'superpowers.codeReader.editNote'
|
||||
|
||||
/** 弹 VS Code 顶部的单行输入框(原值整段选中):回车保存,Esc 或点别处不改,清空后保存即删除。 */
|
||||
async function editNote(notes: FileNotes, node: FileNode | undefined): Promise<void> {
|
||||
if (!node)
|
||||
return // 只挂在侧栏的行上,别处调用没有目标
|
||||
const current = notes.noteOf(node)
|
||||
const text = await window.showInputBox({
|
||||
title: `${current === undefined ? '添加注释' : '编辑注释'} · ${path.posix.basename(node.uri.path)}`,
|
||||
value: current,
|
||||
prompt: '按 Enter 保存,按 Esc 取消;清空后保存即删除注释',
|
||||
})
|
||||
if (text !== undefined)
|
||||
await notes.save(node, text)
|
||||
}
|
||||
|
||||
export function registerNoteCommands(notes: FileNotes): Disposable {
|
||||
const run = (node?: FileNode): Promise<void> => editNote(notes, node)
|
||||
return Disposable.from(
|
||||
commands.registerCommand(ADD_COMMAND, run),
|
||||
commands.registerCommand(EDIT_COMMAND, run),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { FileDeleteEvent, FileRenameEvent, TreeItem, WorkspaceFolder } from 'vscode'
|
||||
import type { FileNode, FileTreeDecorator } from '../explorer/fileTree'
|
||||
import type { NoteMap } from './noteMap'
|
||||
import { Disposable, EventEmitter, MarkdownString, RelativePattern, Uri, window, workspace } from 'vscode'
|
||||
import { noteDecoration } from './noteItem'
|
||||
import { changedKeys, dropNotes, moveNotes, notesUnder, placeNotes, toNoteKey, withNote } from './noteMap'
|
||||
import { NOTES_FILE, readNotes, writeNotes } from './store'
|
||||
|
||||
/**
|
||||
* 读代码时给目录 / 文件记的注释。
|
||||
* 盘上每个工作区文件夹一份 `.spx/code-notes.json`;内存里留一份给侧栏树画行时同步取用。
|
||||
* 在 VS Code 里改名、挪位置,注释跟着走;删掉就一起删。外部改了文件(git pull、手改)按文件重读。
|
||||
*/
|
||||
export class FileNotes implements FileTreeDecorator, Disposable {
|
||||
private readonly changed = new EventEmitter<Uri[]>()
|
||||
readonly onDidChange = this.changed.event
|
||||
/** 工作区文件夹 Uri → 它的注释 */
|
||||
private readonly notes = new Map<string, NoteMap>()
|
||||
/** 工作区文件夹 Uri → 盯它的 code-notes.json 的监听 */
|
||||
private readonly watchers = new Map<string, Disposable>()
|
||||
private readonly disposables: Disposable[]
|
||||
/** 读写排成一队:自己写到一半时文件监听触发的重读排在写完之后,读不到半截文件;几次改动也不会互相覆盖。 */
|
||||
private queue: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor() {
|
||||
this.disposables = [
|
||||
this.changed,
|
||||
workspace.onDidChangeWorkspaceFolders((e) => {
|
||||
e.removed.forEach(folder => this.unwatch(folder))
|
||||
e.added.forEach(folder => this.watch(folder))
|
||||
}),
|
||||
workspace.onDidRenameFiles(e => this.moved(e)),
|
||||
workspace.onDidDeleteFiles(e => this.deleted(e)),
|
||||
]
|
||||
for (const folder of workspace.workspaceFolders ?? [])
|
||||
this.watch(folder)
|
||||
}
|
||||
|
||||
/** 这一行的注释;工作区根节点不记注释。 */
|
||||
noteOf(node: FileNode): string | undefined {
|
||||
const key = toNoteKey(node.folder.uri.path, node.uri.path)
|
||||
return key === undefined ? undefined : this.notes.get(node.folder.uri.toString())?.[key]
|
||||
}
|
||||
|
||||
decorate(item: TreeItem, node: FileNode): void {
|
||||
const key = toNoteKey(node.folder.uri.path, node.uri.path)
|
||||
const decoration = key === undefined ? undefined : noteDecoration(item.contextValue ?? '', key, this.noteOf(node))
|
||||
if (!decoration)
|
||||
return
|
||||
item.description = decoration.description
|
||||
item.tooltip = new MarkdownString(decoration.tooltip)
|
||||
item.contextValue = decoration.contextValue
|
||||
}
|
||||
|
||||
/** 保存注释:去掉首尾空白,空的就删掉这条。 */
|
||||
async save(node: FileNode, text: string): Promise<void> {
|
||||
const key = toNoteKey(node.folder.uri.path, node.uri.path)
|
||||
if (key !== undefined)
|
||||
await this.update(node.folder, notes => withNote(notes, key, text))
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const disposable of [...this.disposables, ...this.watchers.values()])
|
||||
disposable.dispose()
|
||||
this.watchers.clear()
|
||||
}
|
||||
|
||||
private watch(folder: WorkspaceFolder): void {
|
||||
const watcher = workspace.createFileSystemWatcher(new RelativePattern(folder, NOTES_FILE))
|
||||
const reload = (): void => {
|
||||
void this.enqueue(async () => this.apply(folder, await readNotes(folder.uri.fsPath)))
|
||||
}
|
||||
this.watchers.set(folder.uri.toString(), Disposable.from(
|
||||
watcher,
|
||||
watcher.onDidChange(reload),
|
||||
watcher.onDidCreate(reload),
|
||||
watcher.onDidDelete(reload),
|
||||
))
|
||||
reload()
|
||||
}
|
||||
|
||||
private unwatch(folder: WorkspaceFolder): void {
|
||||
const id = folder.uri.toString()
|
||||
this.watchers.get(id)?.dispose()
|
||||
this.watchers.delete(id)
|
||||
this.notes.delete(id)
|
||||
}
|
||||
|
||||
private moved(e: FileRenameEvent): void {
|
||||
for (const { oldUri, newUri } of e.files) {
|
||||
const from = workspace.getWorkspaceFolder(oldUri)
|
||||
const fromKey = from && toNoteKey(from.uri.path, oldUri.path)
|
||||
if (!from || fromKey === undefined)
|
||||
continue
|
||||
const to = workspace.getWorkspaceFolder(newUri)
|
||||
const toKey = to && toNoteKey(to.uri.path, newUri.path)
|
||||
if (to && toKey !== undefined && to.uri.toString() === from.uri.toString()) {
|
||||
void this.update(from, notes => moveNotes(notes, fromKey, toKey))
|
||||
continue
|
||||
}
|
||||
// 挪到了另一个工作区文件夹:两份文件各改一次,队列保证先取出再放进去;挪出工作区就不要了
|
||||
let moving: NoteMap = {}
|
||||
void this.update(from, (notes) => {
|
||||
moving = notesUnder(notes, fromKey)
|
||||
return dropNotes(notes, fromKey)
|
||||
})
|
||||
if (to && toKey !== undefined)
|
||||
void this.update(to, notes => placeNotes(notes, toKey, moving))
|
||||
}
|
||||
}
|
||||
|
||||
private deleted(e: FileDeleteEvent): void {
|
||||
for (const uri of e.files) {
|
||||
const folder = workspace.getWorkspaceFolder(uri)
|
||||
const key = folder && toNoteKey(folder.uri.path, uri.path)
|
||||
if (folder && key !== undefined)
|
||||
void this.update(folder, notes => dropNotes(notes, key))
|
||||
}
|
||||
}
|
||||
|
||||
/** 读盘上最新的一份改好再写回;没变就不写,所以第一条注释保存之前不会生成文件。 */
|
||||
private update(folder: WorkspaceFolder, change: (notes: NoteMap) => NoteMap): Promise<void> {
|
||||
return this.enqueue(async () => {
|
||||
const before = await readNotes(folder.uri.fsPath)
|
||||
const after = change(before)
|
||||
if (changedKeys(before, after).length === 0)
|
||||
return
|
||||
await writeNotes(folder.uri.fsPath, after)
|
||||
this.apply(folder, after)
|
||||
})
|
||||
}
|
||||
|
||||
/** 换上新的一份,报出注释变了的那些行。 */
|
||||
private apply(folder: WorkspaceFolder, notes: NoteMap): void {
|
||||
const id = folder.uri.toString()
|
||||
if (!this.watchers.has(id))
|
||||
return // 排队期间这个文件夹已经移出工作区
|
||||
const keys = changedKeys(this.notes.get(id) ?? {}, notes)
|
||||
this.notes.set(id, notes)
|
||||
if (keys.length > 0)
|
||||
this.changed.fire(keys.map(key => Uri.joinPath(folder.uri, ...key.split('/'))))
|
||||
}
|
||||
|
||||
private enqueue(task: () => Promise<void>): Promise<void> {
|
||||
const run = this.queue.then(task).catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
void window.showErrorMessage(`文件注释读写 ${NOTES_FILE} 失败:${message}`)
|
||||
})
|
||||
this.queue = run
|
||||
return run
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { entryContext } from '../explorer/fileEntries'
|
||||
import { escapeMarkdown, noteDecoration } from './noteItem'
|
||||
|
||||
describe('树上的一行', () => {
|
||||
it('没有注释的行保持原样', () => {
|
||||
expect(noteDecoration('file', 'src/a.ts', undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('有注释:名字后面灰字是注释,contextValue 加 noted,提示里粗体路径下一行注释全文', () => {
|
||||
expect(noteDecoration('file', 'campaign/order_bonus.py', '下单返利:订单完成后按活动规则发积分')).toEqual({
|
||||
description: '下单返利:订单完成后按活动规则发积分',
|
||||
tooltip: '**campaign\\/order\\_bonus\\.py** \n下单返利:订单完成后按活动规则发积分',
|
||||
contextValue: 'file noted',
|
||||
})
|
||||
expect(noteDecoration('folder', 'campaign', '营销活动')?.contextValue).toBe('folder noted')
|
||||
})
|
||||
|
||||
it('注释里的 Markdown 符号原样显示', () => {
|
||||
expect(escapeMarkdown('**别加粗** <b>x</b> [链接](command:x) `代码` # 标题 $(icon)'))
|
||||
.toBe('\\*\\*别加粗\\*\\* \\<b\\>x\\<\\/b\\> \\[链接\\]\\(command\\:x\\) \\`代码\\` \\# 标题 \\$\\(icon\\)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('菜单按 contextValue 挂按钮', () => {
|
||||
interface MenuItem { command: string, when: string, group: string }
|
||||
const menus = (JSON.parse(readFileSync(path.resolve(__dirname, '../../../package.json'), 'utf8')) as {
|
||||
contributes: { menus: Record<string, MenuItem[]> }
|
||||
}).contributes.menus['view/item/context']
|
||||
|
||||
/** 这一行在某个组(行尾按钮 inline / 右键菜单)里有哪些注释命令 */
|
||||
function commandsFor(contextValue: string, group: string): string[] {
|
||||
return menus
|
||||
.filter(item => item.group === group && item.when.includes('view == superpowers.codeReader.files'))
|
||||
.filter((item) => {
|
||||
const pattern = /viewItem =~ \/(.+)\/$/.exec(item.when)?.[1]
|
||||
return pattern !== undefined && new RegExp(pattern).test(contextValue)
|
||||
})
|
||||
.map(item => item.command)
|
||||
}
|
||||
|
||||
const noted = (context: string): string => noteDecoration(context, 'x', '注释')!.contextValue
|
||||
|
||||
it.each(['inline', 'notes'])('%s:没注释的目录 / 文件只有「添加注释」,有注释的只有「编辑注释」,工作区根都没有', (group) => {
|
||||
for (const context of [entryContext({ isDirectory: false, isRoot: false }), entryContext({ isDirectory: true, isRoot: false })]) {
|
||||
expect(commandsFor(context, group)).toEqual(['superpowers.codeReader.addNote'])
|
||||
expect(commandsFor(noted(context), group)).toEqual(['superpowers.codeReader.editNote'])
|
||||
}
|
||||
expect(commandsFor(entryContext({ isDirectory: true, isRoot: true }), group)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
/** 树上有注释的一行怎么显示。 */
|
||||
export interface NoteDecoration {
|
||||
/** 名字后面的灰字,VS Code 放不下时自己截断 */
|
||||
description: string
|
||||
/** 悬停提示(Markdown):第一行粗体的相对路径,下一行注释全文 */
|
||||
tooltip: string
|
||||
/** 末尾加上 ` noted`:菜单的 when 子句靠它在「添加注释」和「编辑注释」之间换 */
|
||||
contextValue: string
|
||||
}
|
||||
|
||||
/** 每个 ASCII 标点前加反斜杠(CommonMark 允许任何 ASCII 标点这样转义),注释里的 `*`、`_`、`<`、`[` 等原样显示。 */
|
||||
export function escapeMarkdown(text: string): string {
|
||||
return text.replace(/[\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E]/g, '\\$&')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param contextValue 这一行原本的 contextValue(`file` / `folder`)
|
||||
* @param key 相对工作区文件夹的路径
|
||||
* @returns 没有注释时 undefined,这一行保持原样
|
||||
*/
|
||||
export function noteDecoration(contextValue: string, key: string, note: string | undefined): NoteDecoration | undefined {
|
||||
if (note === undefined)
|
||||
return undefined
|
||||
return {
|
||||
description: note,
|
||||
tooltip: `**${escapeMarkdown(key)}** \n${escapeMarkdown(note)}`,
|
||||
contextValue: `${contextValue} noted`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { changedKeys, dropNotes, moveNotes, normalizeKey, notesUnder, placeNotes, toNoteKey, withNote } from './noteMap'
|
||||
|
||||
describe('注释的键', () => {
|
||||
it('相对工作区文件夹、用 / 分隔', () => {
|
||||
expect(toNoteKey('/work/luffy', '/work/luffy/backend/src/campaign')).toBe('backend/src/campaign')
|
||||
expect(toNoteKey('/work/luffy/', '/work/luffy/a.ts')).toBe('a.ts')
|
||||
// Windows 上 Uri.path 也是 / 分隔,盘符在最前面
|
||||
expect(toNoteKey('/c:/work/luffy', '/c:/work/luffy/src/a.ts')).toBe('src/a.ts')
|
||||
})
|
||||
|
||||
it('工作区文件夹本身和外面的路径没有键', () => {
|
||||
expect(toNoteKey('/work/luffy', '/work/luffy')).toBeUndefined()
|
||||
expect(toNoteKey('/work/luffy', '/work/other/a.ts')).toBeUndefined()
|
||||
expect(toNoteKey('/work/luffy', '/work/luffy-2/a.ts')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('手写的键规范成同一种写法,不合法的丢掉', () => {
|
||||
expect(normalizeKey('./src/a.ts')).toBe('src/a.ts')
|
||||
expect(normalizeKey('src//campaign/')).toBe('src/campaign')
|
||||
expect(normalizeKey('src/x/../a.ts')).toBe('src/a.ts')
|
||||
expect(normalizeKey('')).toBeUndefined()
|
||||
expect(normalizeKey('.')).toBeUndefined()
|
||||
expect(normalizeKey('../a.ts')).toBeUndefined()
|
||||
expect(normalizeKey('/etc/passwd')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('保存注释', () => {
|
||||
it('去掉首尾空白,不动别的注释', () => {
|
||||
expect(withNote({ 'b.ts': 'B' }, 'a.ts', ' 营销活动 ')).toEqual({ 'a.ts': '营销活动', 'b.ts': 'B' })
|
||||
})
|
||||
|
||||
it('改已有的注释', () => {
|
||||
expect(withNote({ 'a.ts': '旧' }, 'a.ts', '新')).toEqual({ 'a.ts': '新' })
|
||||
})
|
||||
|
||||
it('清空或只有空白就删掉这条', () => {
|
||||
expect(withNote({ 'a.ts': 'A', 'b.ts': 'B' }, 'a.ts', '')).toEqual({ 'b.ts': 'B' })
|
||||
expect(withNote({ 'a.ts': 'A' }, 'a.ts', ' ')).toEqual({})
|
||||
})
|
||||
|
||||
it('不改传进来的那份', () => {
|
||||
const notes = { 'a.ts': 'A' }
|
||||
withNote(notes, 'a.ts', '')
|
||||
expect(notes).toEqual({ 'a.ts': 'A' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('改名、挪位置', () => {
|
||||
const notes = {
|
||||
'campaign': '营销活动',
|
||||
'campaign/benefits.py': '权益发放',
|
||||
'campaign/console/views.py': '后台',
|
||||
'campaign2/x.py': '名字只是前缀相同',
|
||||
'user/routes.py': '用户接口',
|
||||
}
|
||||
|
||||
it('文件改名,注释跟着走', () => {
|
||||
expect(moveNotes(notes, 'user/routes.py', 'user/api.py')).toEqual({
|
||||
'campaign': '营销活动',
|
||||
'campaign/benefits.py': '权益发放',
|
||||
'campaign/console/views.py': '后台',
|
||||
'campaign2/x.py': '名字只是前缀相同',
|
||||
'user/api.py': '用户接口',
|
||||
})
|
||||
})
|
||||
|
||||
it('目录挪位置,里面各层的注释一起走,名字只是前缀相同的不动', () => {
|
||||
expect(moveNotes(notes, 'campaign', 'marketing/campaign')).toEqual({
|
||||
'marketing/campaign': '营销活动',
|
||||
'marketing/campaign/benefits.py': '权益发放',
|
||||
'marketing/campaign/console/views.py': '后台',
|
||||
'campaign2/x.py': '名字只是前缀相同',
|
||||
'user/routes.py': '用户接口',
|
||||
})
|
||||
})
|
||||
|
||||
it('没有注释的目录挪走,里面有注释的文件照样跟着走', () => {
|
||||
expect(moveNotes({ 'src/a/b.ts': 'B' }, 'src/a', 'lib/a')).toEqual({ 'lib/a/b.ts': 'B' })
|
||||
})
|
||||
|
||||
it('挪到另一个工作区文件夹:先取出再放进去', () => {
|
||||
const moving = notesUnder(notes, 'campaign')
|
||||
expect(moving).toEqual({ '': '营销活动', 'benefits.py': '权益发放', 'console/views.py': '后台' })
|
||||
expect(placeNotes({ 'other.ts': 'O' }, 'pkg/campaign', moving)).toEqual({
|
||||
'other.ts': 'O',
|
||||
'pkg/campaign': '营销活动',
|
||||
'pkg/campaign/benefits.py': '权益发放',
|
||||
'pkg/campaign/console/views.py': '后台',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('删除', () => {
|
||||
it('删目录连带里面各层的注释,名字只是前缀相同的留着', () => {
|
||||
expect(dropNotes({
|
||||
'campaign': '营销活动',
|
||||
'campaign/benefits.py': '权益发放',
|
||||
'campaign/console/views.py': '后台',
|
||||
'campaign2/x.py': '留着',
|
||||
}, 'campaign')).toEqual({ 'campaign2/x.py': '留着' })
|
||||
})
|
||||
|
||||
it('删文件只删它自己', () => {
|
||||
expect(dropNotes({ 'a.ts': 'A', 'b.ts': 'B' }, 'a.ts')).toEqual({ 'b.ts': 'B' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('变了哪些行', () => {
|
||||
it('增、删、改都算,没变的不算', () => {
|
||||
expect(changedKeys({ a: '1', b: '2', c: '3' }, { a: '1', b: '二', d: '4' }).sort()).toEqual(['b', 'c', 'd'])
|
||||
expect(changedKeys({ a: '1' }, { a: '1' })).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as path from 'node:path'
|
||||
|
||||
/** 一个工作区文件夹里的全部注释:键是相对这个文件夹的路径(`/` 分隔),值是注释。 */
|
||||
export type NoteMap = Record<string, string>
|
||||
|
||||
/** 规范成注释的键:去掉 `./`、重复和结尾的 `/`;工作区文件夹本身、文件夹外面的路径没有键。 */
|
||||
export function normalizeKey(relativePath: string): string | undefined {
|
||||
const key = path.posix.normalize(relativePath).replace(/\/+$/, '')
|
||||
return key === '' || key === '.' || key === '..' || key.startsWith('../') || key.startsWith('/') ? undefined : key
|
||||
}
|
||||
|
||||
/** @param rootPath 工作区文件夹的 `Uri.path`;@param targetPath 目录 / 文件的 `Uri.path` */
|
||||
export function toNoteKey(rootPath: string, targetPath: string): string | undefined {
|
||||
return normalizeKey(path.posix.relative(rootPath, targetPath))
|
||||
}
|
||||
|
||||
/** 保存一条注释:去掉首尾空白,空的就删掉这条。 */
|
||||
export function withNote(notes: NoteMap, key: string, text: string): NoteMap {
|
||||
const next = { ...notes }
|
||||
delete next[key]
|
||||
const note = text.trim()
|
||||
if (note !== '')
|
||||
next[key] = note
|
||||
return next
|
||||
}
|
||||
|
||||
function isUnder(candidate: string, key: string): boolean {
|
||||
return candidate === key || candidate.startsWith(`${key}/`)
|
||||
}
|
||||
|
||||
/** key 自己和它下面各层的注释,键换成相对 key 的路径(key 自己是 `''`)。 */
|
||||
export function notesUnder(notes: NoteMap, key: string): NoteMap {
|
||||
return Object.fromEntries(Object.entries(notes)
|
||||
.filter(([candidate]) => isUnder(candidate, key))
|
||||
.map(([candidate, note]) => [candidate.slice(key.length + 1), note]))
|
||||
}
|
||||
|
||||
/** 删掉 key 自己和它下面各层的注释(目录被删时连带里面的)。 */
|
||||
export function dropNotes(notes: NoteMap, key: string): NoteMap {
|
||||
return Object.fromEntries(Object.entries(notes).filter(([candidate]) => !isUnder(candidate, key)))
|
||||
}
|
||||
|
||||
/** 把 notesUnder 取出的注释放到 key 下面。 */
|
||||
export function placeNotes(notes: NoteMap, key: string, under: NoteMap): NoteMap {
|
||||
const placed = Object.entries(under).map(([rest, note]) => [rest === '' ? key : `${key}/${rest}`, note])
|
||||
return { ...notes, ...Object.fromEntries(placed) }
|
||||
}
|
||||
|
||||
/** 改名 / 挪位置:注释跟着走,目录连带里面各层的注释一起走。 */
|
||||
export function moveNotes(notes: NoteMap, from: string, to: string): NoteMap {
|
||||
return placeNotes(dropNotes(notes, from), to, notesUnder(notes, from))
|
||||
}
|
||||
|
||||
/** 两份注释里有增、删、改的键。 */
|
||||
export function changedKeys(before: NoteMap, after: NoteMap): string[] {
|
||||
const keys = new Set([...Object.keys(before), ...Object.keys(after)])
|
||||
return [...keys].filter(key => before[key] !== after[key])
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { promises as fsp } from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { readNotes, writeNotes } from './store'
|
||||
|
||||
let root: string
|
||||
const notesFile = (): string => path.join(root, '.spx', 'code-notes.json')
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await fsp.mkdtemp(path.join(os.tmpdir(), 'code-notes-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fsp.rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function writeRaw(content: string): Promise<void> {
|
||||
await fsp.mkdir(path.dirname(notesFile()), { recursive: true })
|
||||
await fsp.writeFile(notesFile(), content, 'utf8')
|
||||
}
|
||||
|
||||
describe('读注释', () => {
|
||||
it('文件不存在返回空表', async () => {
|
||||
expect(await readNotes(root)).toEqual({})
|
||||
})
|
||||
|
||||
it('解析失败或不是对象返回空表', async () => {
|
||||
await writeRaw('{ "a.ts": ')
|
||||
expect(await readNotes(root)).toEqual({})
|
||||
await writeRaw('["a.ts"]')
|
||||
expect(await readNotes(root)).toEqual({})
|
||||
await writeRaw('null')
|
||||
expect(await readNotes(root)).toEqual({})
|
||||
})
|
||||
|
||||
it('跳过值不是字符串、只有空白、键不合法的条目,键按规范写法', async () => {
|
||||
await writeRaw(JSON.stringify({
|
||||
'./src/a.ts': ' 入口 ',
|
||||
'src/b.ts': 42,
|
||||
'src/c.ts': ' ',
|
||||
'../outside.ts': '外面',
|
||||
'campaign/': '营销活动',
|
||||
}))
|
||||
expect(await readNotes(root)).toEqual({ 'src/a.ts': '入口', 'campaign': '营销活动' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('写注释', () => {
|
||||
it('没有 .spx 目录时建出来,键排好序、缩进两格、末尾换行', async () => {
|
||||
await writeNotes(root, { 'user/routes.py': '用户接口', 'campaign': '营销活动', 'campaign/benefits.py': '权益发放' })
|
||||
expect(await fsp.readFile(notesFile(), 'utf8')).toBe(`${JSON.stringify({
|
||||
'campaign': '营销活动',
|
||||
'campaign/benefits.py': '权益发放',
|
||||
'user/routes.py': '用户接口',
|
||||
}, null, 2)}\n`)
|
||||
})
|
||||
|
||||
it('写进去的读得回来', async () => {
|
||||
const notes = { 'a.ts': '含 "引号" 和 \\ 反斜杠', 'dir': '目录' }
|
||||
await writeNotes(root, notes)
|
||||
expect(await readNotes(root)).toEqual(notes)
|
||||
})
|
||||
|
||||
it('删到一条不剩时文件留着,内容是空对象', async () => {
|
||||
await writeNotes(root, { 'a.ts': 'A' })
|
||||
await writeNotes(root, {})
|
||||
expect(await fsp.readFile(notesFile(), 'utf8')).toBe('{}\n')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 文件注释持久化。
|
||||
*
|
||||
* 每个工作区文件夹一份,落盘到 `<workspace>/.spx/code-notes.json`,形状是
|
||||
* `{ "相对路径": "注释" }`。文件不存在或解析失败时返回 `{}`;
|
||||
* 写入时按需 `mkdir -p .spx`,键排好序再写,git diff 稳定。
|
||||
*/
|
||||
|
||||
import type { NoteMap } from './noteMap'
|
||||
import { promises as fsp } from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { normalizeKey } from './noteMap'
|
||||
|
||||
/** 相对工作区文件夹的位置,文件监听也用它。 */
|
||||
export const NOTES_FILE = '.spx/code-notes.json'
|
||||
|
||||
function notesFile(workspaceRoot: string): string {
|
||||
return path.join(workspaceRoot, ...NOTES_FILE.split('/'))
|
||||
}
|
||||
|
||||
/** 读取工作区文件夹的注释。文件缺失 / 解析失败 / 不是对象都返回 `{}`;键不合法、值不是字符串或是空白的条目跳过。 */
|
||||
export async function readNotes(workspaceRoot: string): Promise<NoteMap> {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await fsp.readFile(notesFile(workspaceRoot), 'utf8')
|
||||
}
|
||||
catch {
|
||||
return {}
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
return {}
|
||||
const notes: NoteMap = {}
|
||||
for (const [rawKey, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
const key = normalizeKey(rawKey)
|
||||
const note = typeof value === 'string' ? value.trim() : ''
|
||||
if (key !== undefined && note !== '')
|
||||
notes[key] = note
|
||||
}
|
||||
return notes
|
||||
}
|
||||
catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入工作区文件夹的注释(空表也写,文件留着)。父目录按需创建。 */
|
||||
export async function writeNotes(workspaceRoot: string, notes: NoteMap): Promise<void> {
|
||||
await fsp.mkdir(path.dirname(notesFile(workspaceRoot)), { recursive: true })
|
||||
const sorted = Object.fromEntries(Object.entries(notes).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))
|
||||
await fsp.writeFile(notesFile(workspaceRoot), `${JSON.stringify(sorted, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
Reference in new issue
Block a user