From 2b823a32caf4449df733b21c54724cde4a07e5cc Mon Sep 17 00:00:00 2001 From: cruldra Date: Thu, 24 Sep 2026 21:36:00 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E5=85=A5=E5=8F=A3=E3=80=81=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=A0=91=E4=B8=8E=E9=A2=84=E8=A7=88=E7=BC=96=E8=BE=91=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/package.json | 85 +++++++++++- vscode/pnpm-lock.yaml | 29 +++- vscode/res/reader.svg | 8 ++ vscode/src/codeReader/editText.test.ts | 28 +++- vscode/src/codeReader/editText.ts | 30 +++++ .../codeReader/editor/documentMessage.test.ts | 54 ++++++++ .../src/codeReader/editor/documentMessage.ts | 39 ++++++ vscode/src/codeReader/editor/messages.ts | 26 ++++ vscode/src/codeReader/editor/previewEditor.ts | 127 ++++++++++++++++++ .../codeReader/explorer/fileEntries.test.ts | 60 +++++++++ vscode/src/codeReader/explorer/fileEntries.ts | 43 ++++++ vscode/src/codeReader/explorer/fileTree.ts | 93 +++++++++++++ vscode/src/codeReader/index.ts | 81 +++++++++++ vscode/src/extension.ts | 11 +- vscode/src/panel/KanbanPanel.ts | 64 ++------- vscode/src/panel/handlers/handoffFlow.ts | 2 +- vscode/src/panel/handlers/issues.ts | 2 +- vscode/src/panel/handlers/prFiles.ts | 2 +- vscode/src/panel/handlers/sessions.ts | 2 +- vscode/src/panel/handlers/settings.ts | 2 +- vscode/src/panel/handlers/toolbar.ts | 2 +- vscode/src/panel/handlers/worktree.ts | 2 +- vscode/src/panel/handlers/youtrackIssues.ts | 2 +- vscode/src/webview/html.ts | 46 +++++++ 24 files changed, 768 insertions(+), 72 deletions(-) create mode 100644 vscode/res/reader.svg create mode 100644 vscode/src/codeReader/editor/documentMessage.test.ts create mode 100644 vscode/src/codeReader/editor/documentMessage.ts create mode 100644 vscode/src/codeReader/editor/messages.ts create mode 100644 vscode/src/codeReader/editor/previewEditor.ts create mode 100644 vscode/src/codeReader/explorer/fileEntries.test.ts create mode 100644 vscode/src/codeReader/explorer/fileEntries.ts create mode 100644 vscode/src/codeReader/explorer/fileTree.ts create mode 100644 vscode/src/codeReader/index.ts create mode 100644 vscode/src/webview/html.ts diff --git a/vscode/package.json b/vscode/package.json index 9c397a4..c3bfa9a 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -33,7 +33,12 @@ "onView:superpowers.kanban", "onCommand:superpowers.openKanban", "onCommand:superpowers.setGiteaToken", - "onUri" + "onUri", + "onView:superpowers.codeReader.files", + "onCustomEditor:superpowers.codeReader.preview", + "onCommand:superpowers.codeReader.open", + "onCommand:superpowers.codeReader.showPreview", + "onCommand:superpowers.codeReader.showSource" ], "contributes": { "viewsContainers": { @@ -42,6 +47,11 @@ "id": "superpowers", "title": "Superpowers", "icon": "res/icon.svg" + }, + { + "id": "superpowers-reader", + "title": "代码阅读", + "icon": "res/reader.svg" } ] }, @@ -51,8 +61,26 @@ "id": "superpowers.kanban", "name": "Kanban" } + ], + "superpowers-reader": [ + { + "id": "superpowers.codeReader.files", + "name": "代码阅读" + } ] }, + "customEditors": [ + { + "viewType": "superpowers.codeReader.preview", + "displayName": "代码阅读预览", + "selector": [ + { + "filenamePattern": "*" + } + ], + "priority": "option" + } + ], "viewsWelcome": [ { "view": "superpowers.kanban", @@ -74,6 +102,27 @@ "command": "superpowers.setGiteaToken", "title": "Set Gitea Token", "category": "Superpowers" + }, + { + "command": "superpowers.codeReader.refresh", + "title": "刷新", + "category": "代码阅读", + "icon": "$(refresh)" + }, + { + "command": "superpowers.codeReader.open", + "title": "打开", + "category": "代码阅读" + }, + { + "command": "superpowers.codeReader.showPreview", + "title": "预览", + "category": "代码阅读" + }, + { + "command": "superpowers.codeReader.showSource", + "title": "编辑", + "category": "代码阅读" } ], "menus": { @@ -92,6 +141,37 @@ "command": "superpowers.setGiteaToken", "when": "view == superpowers.kanban", "group": "navigation" + }, + { + "command": "superpowers.codeReader.refresh", + "when": "view == superpowers.codeReader.files", + "group": "navigation" + } + ], + "editor/title": [ + { + "command": "superpowers.codeReader.showPreview", + "when": "activeEditor == workbench.editors.files.textFileEditor && (resourceLangId =~ /^(typescript|typescriptreact|javascript|javascriptreact|python)$/ || resourceLangId in superpowers.codeReader.languageIds)", + "group": "navigation" + }, + { + "command": "superpowers.codeReader.showSource", + "when": "activeCustomEditorId == superpowers.codeReader.preview", + "group": "navigation" + } + ], + "commandPalette": [ + { + "command": "superpowers.codeReader.open", + "when": "false" + }, + { + "command": "superpowers.codeReader.showPreview", + "when": "activeEditor == workbench.editors.files.textFileEditor && (resourceLangId =~ /^(typescript|typescriptreact|javascript|javascriptreact|python)$/ || resourceLangId in superpowers.codeReader.languageIds)" + }, + { + "command": "superpowers.codeReader.showSource", + "when": "activeCustomEditorId == superpowers.codeReader.preview" } ] } @@ -110,7 +190,8 @@ "dependencies": { "@vscode/tree-sitter-wasm": "^0.3.1", "diff": "^9.0.0", - "handlebars": "^4.7.9" + "handlebars": "^4.7.9", + "minimatch": "^10.2.6" }, "devDependencies": { "@antfu/eslint-config": "^6.7.3", diff --git a/vscode/pnpm-lock.yaml b/vscode/pnpm-lock.yaml index 41e9d76..cab7074 100644 --- a/vscode/pnpm-lock.yaml +++ b/vscode/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: handlebars: specifier: ^4.7.9 version: 4.7.9 + minimatch: + specifier: ^10.2.6 + version: 10.2.6 devDependencies: '@antfu/eslint-config': specifier: ^6.7.3 @@ -1532,6 +1535,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1558,6 +1565,10 @@ packages: brace-expansion@2.0.1: resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -2789,9 +2800,9 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} - minimatch@10.0.1: - resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} - engines: {node: 20 || >=22} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -5016,6 +5027,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + base64-js@1.5.1: optional: true @@ -5045,6 +5058,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -5829,7 +5846,7 @@ snapshots: dependencies: foreground-child: 3.1.1 jackspeak: 4.0.1 - minimatch: 10.0.1 + minimatch: 10.2.6 minipass: 7.1.2 package-json-from-dist: 1.0.0 path-scurry: 2.0.0 @@ -6532,9 +6549,9 @@ snapshots: mimic-response@3.1.0: optional: true - minimatch@10.0.1: + minimatch@10.2.6: dependencies: - brace-expansion: 2.0.1 + brace-expansion: 5.0.12 minimatch@3.1.2: dependencies: diff --git a/vscode/res/reader.svg b/vscode/res/reader.svg new file mode 100644 index 0000000..b8ea4ab --- /dev/null +++ b/vscode/res/reader.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/vscode/src/codeReader/editText.test.ts b/vscode/src/codeReader/editText.test.ts index 424e9a3..ef9f006 100644 --- a/vscode/src/codeReader/editText.test.ts +++ b/vscode/src/codeReader/editText.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { toBlockViews } from './blockView' -import { commonIndent, reindentBlock, replaceLines } from './editText' +import { blockReplacement, commonIndent, reindentBlock, replaceLines } from './editText' import { createTypeScriptAdapter } from './languages/typescript' import { readFixture, WASM_DIR, withBlocks } from './testSupport' @@ -44,3 +44,29 @@ describe('行替换', () => { expect(replaceLines(source, view.startLine, view.endLine, view.code), view.id).toBe(source) }) }) + +describe('块内修改写回的替换范围', () => { + const doc = ['class A {', ' run() {', ' go()', ' }', '}'] + const lineAt = (n: number): string => doc[n] + + it('从起始行行首替换到结束行行尾,新文本补回原来的缩进', () => { + expect(blockReplacement(lineAt, doc.length, 1, 2, 'run(): void {\n go(1)')).toEqual({ + startLine: 1, + endLine: 2, + endCharacter: 8, + text: ' run(): void {\n go(1)', + unchanged: false, + }) + }) + + it('改完和原来一样时标记 unchanged', () => { + expect(blockReplacement(lineAt, doc.length, 1, 2, 'run() {\n go()')?.unchanged).toBe(true) + }) + + it('行号已不在文档里时不替换', () => { + expect(blockReplacement(lineAt, doc.length, 3, 5, 'x')).toBeUndefined() + expect(blockReplacement(lineAt, doc.length, -1, 0, 'x')).toBeUndefined() + expect(blockReplacement(lineAt, doc.length, 2, 1, 'x')).toBeUndefined() + expect(blockReplacement(lineAt, doc.length, 0.5, 1, 'x')).toBeUndefined() + }) +}) diff --git a/vscode/src/codeReader/editText.ts b/vscode/src/codeReader/editText.ts index 85934f8..e322e8d 100644 --- a/vscode/src/codeReader/editText.ts +++ b/vscode/src/codeReader/editText.ts @@ -39,3 +39,33 @@ export function replaceLines(source: string, startLine: number, endLine: number, const replacement = splitLines(reindentBlock(lines.slice(startLine, endLine + 1), text)) return [...lines.slice(0, startLine), ...replacement, ...lines.slice(endLine + 1)].join(eol) } + +/** 写回文件时要整行替换的一段:从 startLine 行首到 endLine 行尾(不含换行符)。 */ +export interface LineReplacement { + startLine: number + endLine: number + /** endLine 这一行的长度。 */ + endCharacter: number + /** 补回缩进后的新文本,行间用 \n(写进文档时 VS Code 按文档的换行符转换)。 */ + text: string + /** 和原来那几行一字不差:不必写回,免得留下一次空的撤销记录。 */ + unchanged: boolean +} + +/** + * 预览里块内改完的文本 → 对当前文档的整行替换。行号已不在文档里(文档刚被别处改短)时返回 undefined。 + * @param lineAt 读文档第 n 行(不含换行符) + */ +export function blockReplacement(lineAt: (line: number) => string, lineCount: number, startLine: number, endLine: number, text: string): LineReplacement | undefined { + if (!Number.isInteger(startLine) || !Number.isInteger(endLine) || startLine < 0 || endLine < startLine || endLine >= lineCount) + return undefined + const original = Array.from({ length: endLine - startLine + 1 }, (_, i) => lineAt(startLine + i)) + const replacement = reindentBlock(original, text) + return { + startLine, + endLine, + endCharacter: original[original.length - 1].length, + text: replacement, + unchanged: replacement === original.join('\n'), + } +} diff --git a/vscode/src/codeReader/editor/documentMessage.test.ts b/vscode/src/codeReader/editor/documentMessage.test.ts new file mode 100644 index 0000000..ba770aa --- /dev/null +++ b/vscode/src/codeReader/editor/documentMessage.test.ts @@ -0,0 +1,54 @@ +import type { Memento } from 'vscode' +import { describe, expect, it } from 'vitest' +import { createTypeScriptAdapter } from '../languages/typescript' +import { CodeReaderRegistry } from '../registry' +import { runtime, WASM_DIR } from '../testSupport' +import { typescriptZhTranslator } from '../translators/typescriptZh' +import { documentMessage } from './documentMessage' + +function registryWithTypeScript(): CodeReaderRegistry { + const store: Record = {} + const registry = new CodeReaderRegistry({ + get: (key: string): T | undefined => store[key] as T | undefined, + update: async (key: string, value: unknown) => { + store[key] = value + }, + } as Pick) + registry.registerLanguage(createTypeScriptAdapter(WASM_DIR)) + registry.registerTranslator(typescriptZhTranslator) + return registry +} + +function doc(languageId: string, text: string, version = 3): { languageId: string, version: number, getText: () => string } { + return { languageId, version, getText: () => text } +} + +describe('预览的 document 消息', () => { + it('受支持的语言:带文档版本、代码块、可选翻译和选中的翻译', async () => { + const message = await documentMessage(doc('typescript', 'if (!folder)\n return []\n'), registryWithTypeScript(), runtime) + expect(message).toMatchObject({ + type: 'document', + version: 3, + languageId: 'typescript', + translators: [{ id: typescriptZhTranslator.id, label: typescriptZhTranslator.label }], + translatorId: typescriptZhTranslator.id, + }) + expect(message.error).toBeUndefined() + expect(message.blocks.map(b => b.kind)).toEqual(['if']) + expect(message.blocks[0].children.map(b => b.translation.map(s => s.text).join(''))).toEqual(['返回 空数组']) + }) + + it('没有语言插件的语言:带 error,不解析', async () => { + const message = await documentMessage(doc('rust', 'fn main() {}'), registryWithTypeScript(), runtime) + expect(message.blocks).toEqual([]) + expect(message.translators).toEqual([]) + expect(message.error).toContain('rust') + }) + + it('解析失败:把原因放进 error', async () => { + const failing = { parse: async () => Promise.reject(new Error('语法包加载失败')) } + const message = await documentMessage(doc('typescript', 'const a = 1'), registryWithTypeScript(), failing) + expect(message.blocks).toEqual([]) + expect(message.error).toBe('语法包加载失败') + }) +}) diff --git a/vscode/src/codeReader/editor/documentMessage.ts b/vscode/src/codeReader/editor/documentMessage.ts new file mode 100644 index 0000000..cb9100b --- /dev/null +++ b/vscode/src/codeReader/editor/documentMessage.ts @@ -0,0 +1,39 @@ +import type { CodeReaderRegistry } from '../registry' +import type { TreeSitterRuntime } from '../runtime' +import type { DocumentMessage } from './messages' +import { readBlockViews } from '../blockView' + +/** 生成预览数据要读的那点文档信息(TextDocument 的子集)。 */ +export interface DocumentSnapshot { + languageId: string + version: number + getText: () => string +} + +/** 当前文档 → 发给预览的 document 消息;语言不支持或解析失败时带 error,blocks 为空。 */ +export async function documentMessage( + document: DocumentSnapshot, + registry: Pick, + runtime: Pick, +): Promise { + const { languageId, version } = document + const source = document.getText() + const translator = registry.selectedTranslator(languageId) + const message: DocumentMessage = { + type: 'document', + version, + languageId, + blocks: [], + translators: registry.translatorsFor(languageId).map(({ id, label }) => ({ id, label })), + translatorId: translator?.id ?? '', + } + const adapter = registry.languageFor(languageId) + if (!adapter) + return { ...message, error: `代码阅读还不支持「${languageId}」这种语言` } + try { + return { ...message, blocks: await readBlockViews(runtime, adapter, translator, languageId, source) } + } + catch (err) { + return { ...message, error: err instanceof Error ? err.message : String(err) } + } +} diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts new file mode 100644 index 0000000..ac83bb8 --- /dev/null +++ b/vscode/src/codeReader/editor/messages.ts @@ -0,0 +1,26 @@ +/** + * 代码阅读预览与扩展之间的消息,与 webview 端 `webview-ui/src/codeReader/types.ts` 同构,两边手动保持一致。 + */ + +import type { BlockView } from '../blockView' + +export interface TranslatorOption { id: string, label: string } + +export interface DocumentMessage { + type: 'document' + /** TextDocument.version:webview 靠它判断是不是新一份文档 */ + version: number + languageId: string + blocks: BlockView[] + translators: TranslatorOption[] + translatorId: string + error?: string +} + +export type ExtensionToWebview = DocumentMessage + +/** edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进) */ +export type WebviewToExtension + = | { type: 'ready' } + | { type: 'edit', startLine: number, endLine: number, text: string } + | { type: 'selectTranslator', id: string } diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts new file mode 100644 index 0000000..bd34061 --- /dev/null +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -0,0 +1,127 @@ +import type { CustomTextEditorProvider, Disposable, TextDocument, Uri, Webview, WebviewPanel } from 'vscode' +import type { CodeReaderRegistry } from '../registry' +import type { TreeSitterRuntime } from '../runtime' +import type { ExtensionToWebview, WebviewToExtension } from './messages' +import { Range, window, workspace, WorkspaceEdit } from 'vscode' +import { logger } from '../../logging/logger' +import { webviewHtml, webviewUiRoot } from '../../webview/html' +import { blockReplacement } from '../editText' +import { documentMessage } from './documentMessage' + +export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview' + +/** 连续输入时合并成一次重新解析。 */ +const REFRESH_DELAY_MS = 150 + +/** + * 「代码阅读预览」自定义编辑器。预览和普通编辑器共用同一个 TextDocument: + * 块内修改走 WorkspaceEdit,未保存标记、⌘S、⌘Z 都是 VS Code 原生行为。 + */ +export class PreviewEditorProvider implements CustomTextEditorProvider { + private readonly previews = new Set() + + constructor( + private readonly extensionUri: Uri, + private readonly registry: CodeReaderRegistry, + private readonly runtime: TreeSitterRuntime, + ) {} + + resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void { + panel.webview.options = { enableScripts: true, localResourceRoots: [webviewUiRoot(this.extensionUri)] } + panel.webview.html = webviewHtml(panel.webview, this.extensionUri, 'code-reader.html') + const preview = new Preview(document, panel.webview, this.registry, this.runtime, languageId => this.refresh(languageId)) + this.previews.add(preview) + panel.onDidDispose(() => { + this.previews.delete(preview) + preview.dispose() + }) + } + + /** 让已打开的预览按当前注册表重发;给了 languageId 就只刷这种语言的。 */ + refresh(languageId?: string): void { + for (const preview of this.previews) { + if (languageId === undefined || preview.languageId === languageId) + void preview.push() + } + } +} + +class Preview { + /** webview 发过 ready 之后才发文档,避免脚本还没起来时消息丢掉 */ + private ready = false + /** 解析是异步的:只发最后一次请求的结果,慢的旧结果不能盖掉新的 */ + private latest = 0 + private timer: NodeJS.Timeout | undefined + private readonly disposables: Disposable[] + + constructor( + private readonly document: TextDocument, + private readonly webview: Webview, + private readonly registry: CodeReaderRegistry, + private readonly runtime: TreeSitterRuntime, + private readonly onTranslatorSelected: (languageId: string) => void, + ) { + this.disposables = [ + webview.onDidReceiveMessage((msg: WebviewToExtension) => this.handleMessage(msg)), + workspace.onDidChangeTextDocument((e) => { + if (e.document === document && e.contentChanges.length > 0) + this.schedulePush() + }), + ] + } + + get languageId(): string { + return this.document.languageId + } + + async push(): Promise { + clearTimeout(this.timer) + if (!this.ready) + return + const request = ++this.latest + const message: ExtensionToWebview = await documentMessage(this.document, this.registry, this.runtime) + if (request === this.latest) + void this.webview.postMessage(message) + } + + dispose(): void { + clearTimeout(this.timer) + for (const disposable of this.disposables) + disposable.dispose() + } + + private schedulePush(): void { + clearTimeout(this.timer) + this.timer = setTimeout(() => void this.push(), REFRESH_DELAY_MS) + } + + private async handleMessage(msg: WebviewToExtension): Promise { + switch (msg.type) { + case 'ready': + this.ready = true + await this.push() + return + case 'edit': + await this.applyEdit(msg.startLine, msg.endLine, msg.text) + return + case 'selectTranslator': + await this.registry.selectTranslator(this.languageId, msg.id) + this.onTranslatorSelected(this.languageId) + } + } + + private async applyEdit(startLine: number, endLine: number, text: string): Promise { + const { document } = this + const replacement = blockReplacement(line => document.lineAt(line).text, document.lineCount, startLine, endLine, text) + if (!replacement) { + logger.add({ level: 'warn', source: 'codeReader', message: `块内修改的行号 ${startLine}-${endLine} 已不在 ${document.uri.fsPath} 里,没有写回` }) + return + } + if (replacement.unchanged) + return + const edit = new WorkspaceEdit() + edit.replace(document.uri, new Range(replacement.startLine, 0, replacement.endLine, replacement.endCharacter), replacement.text) + if (!await workspace.applyEdit(edit)) + void window.showErrorMessage(`代码阅读:没能把修改写回 ${workspace.asRelativePath(document.uri)}`) + } +} diff --git a/vscode/src/codeReader/explorer/fileEntries.test.ts b/vscode/src/codeReader/explorer/fileEntries.test.ts new file mode 100644 index 0000000..9aaf653 --- /dev/null +++ b/vscode/src/codeReader/explorer/fileEntries.test.ts @@ -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() + + 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) + }) +}) diff --git a/vscode/src/codeReader/explorer/fileEntries.ts b/vscode/src/codeReader/explorer/fileEntries.ts new file mode 100644 index 0000000..9f39315 --- /dev/null +++ b/vscode/src/codeReader/explorer/fileEntries.ts @@ -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(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 + +/** + * @param relativePath 相对工作区文件夹的路径,用 `/` 分隔 + * @param siblingNames 同一目录下的全部名字(算 `when` 条件用) + */ +export type ExcludeMatcher = (relativePath: string, siblingNames: ReadonlySet) => 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))) + }) +} diff --git a/vscode/src/codeReader/explorer/fileTree.ts b/vscode/src/codeReader/explorer/fileTree.ts new file mode 100644 index 0000000..aee5ada --- /dev/null +++ b/vscode/src/codeReader/explorer/fileTree.ts @@ -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 { + private readonly changed = new EventEmitter() + readonly onDidChangeTreeData: Event = 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 { + 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('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) + } +} diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts new file mode 100644 index 0000000..61d9dba --- /dev/null +++ b/vscode/src/codeReader/index.ts @@ -0,0 +1,81 @@ +import type { ExtensionContext, Uri } from 'vscode' +import type { CodeReaderApi } from './contract' +import { commands, window, workspace } from 'vscode' +import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './editor/previewEditor' +import { FileTreeProvider } from './explorer/fileTree' +import { createPythonAdapter } from './languages/python' +import { createTypeScriptAdapter } from './languages/typescript' +import { CodeReaderRegistry } from './registry' +import { bundledWasmDir, TreeSitterRuntime } from './runtime' +import { pythonZhTranslator } from './translators/pythonZh' +import { typescriptZhTranslator } from './translators/typescriptZh' + +/** 当前语言插件覆盖的 languageId,给 editor/title 的 when 子句判断要不要显示「预览」。 */ +const LANGUAGE_IDS_CONTEXT = 'superpowers.codeReader.languageIds' +const FILES_VIEW = 'superpowers.codeReader.files' +const OPEN_COMMAND = 'superpowers.codeReader.open' +/** VS Code 自带普通文本编辑器的 editor id。 */ +const TEXT_EDITOR = 'default' +/** + * VS Code 自带的「重新打开编辑器的方式」:在原标签页位置换成另一种编辑器。 + * 原文件有未保存改动也不弹保存框——改动在 TextDocument 里,不跟着标签页走。 + */ +const REOPEN_WITH = 'reopenActiveEditorWith' + +/** 按 VS Code 的判断(含 files.associations)取文件的 languageId;图片等二进制文件打不开成文本,返回 undefined。 */ +async function languageOf(uri: Uri): Promise { + try { + return (await workspace.openTextDocument(uri)).languageId + } + catch { + return undefined + } +} + +/** 侧栏点文件:语言受支持就用预览打开,否则用普通编辑器。 */ +async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise { + const languageId = await languageOf(uri) + if (languageId !== undefined && registry.languageFor(languageId)) + // 和 vscode.open 一样按「预览标签」打开(用户关了 workbench.editor.enablePreview 时 VS Code 自会固定) + await commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, { preview: true }) + else + await commands.executeCommand('vscode.open', uri) +} + +/** + * 接入代码阅读:注册表、侧栏文件树、预览编辑器、编辑器右上角的「预览 / 编辑」切换。 + * 内置的语言插件和翻译插件与其他扩展一样通过返回的 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) + + context.subscriptions.push( + registry.onDidChange(() => { + void commands.executeCommand('setContext', LANGUAGE_IDS_CONTEXT, registry.languageIds()) + previews.refresh() + }), + window.registerCustomEditorProvider(PREVIEW_VIEW_TYPE, previews), + files, + window.createTreeView(FILES_VIEW, { treeDataProvider: files, showCollapseAll: true }), + commands.registerCommand('superpowers.codeReader.refresh', () => files.refresh()), + commands.registerCommand(OPEN_COMMAND, (uri: Uri) => openFile(uri, registry)), + // 标题栏按钮传来的是 (文件 Uri, { groupId }),原样转给 VS Code 定位是哪一组的哪个标签 + commands.registerCommand('superpowers.codeReader.showPreview', (...args: unknown[]) => + commands.executeCommand(REOPEN_WITH, PREVIEW_VIEW_TYPE, ...args)), + commands.registerCommand('superpowers.codeReader.showSource', (...args: unknown[]) => + commands.executeCommand(REOPEN_WITH, TEXT_EDITOR, ...args)), + ) + + const api: CodeReaderApi = registry + const wasmDir = bundledWasmDir() + context.subscriptions.push( + api.registerLanguage(createTypeScriptAdapter(wasmDir)), + api.registerLanguage(createPythonAdapter(wasmDir)), + api.registerTranslator(typescriptZhTranslator), + api.registerTranslator(pythonZhTranslator), + ) + return api +} diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 1cde7fa..74fdc28 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -4,9 +4,11 @@ import type { TreeDataProvider, TreeItem, } from 'vscode' +import type { CodeReaderApi } from './codeReader/contract' import { commands, EventEmitter, window } from 'vscode' import { initIdentity } from './auth/identity' import { setProfilesDirOverride } from './cc/profiles' +import { registerCodeReader } from './codeReader' import { logger } from './logging/logger' import { KanbanWebviewPanel } from './panel/KanbanPanel' import { getSettings, migratePromptTemplates, persistSeededProfiles } from './settings/store' @@ -30,7 +32,12 @@ class EmptyTreeProvider implements TreeDataProvider { } } -export function activate(context: ExtensionContext): void { +/** 对外 API:其他扩展声明 extensionDependencies 后从 exports.codeReader 注册语言插件 / 翻译插件。 */ +export interface SuperpowersApi { + codeReader: CodeReaderApi +} + +export function activate(context: ExtensionContext): SuperpowersApi { // Profiles 目录 override 在 panel 未打开时也要生效(list / launch 共用)。 setProfilesDirOverride(getSettings(context).profilesDirectory || undefined) // 存量提示词先迁到 Handlebars,再让 persistSeededProfiles 在迁移结果上补 seed。 @@ -97,6 +104,8 @@ export function activate(context: ExtensionContext): void { KanbanWebviewPanel.requestEditAuth() }), ) + + return { codeReader: registerCodeReader(context) } } export function deactivate(): void {} diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index 1e98d32..2a21bbf 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -11,9 +11,6 @@ import type { SessionTabKind } from './handlers/terminals' import type { IssueOwnershipFacts } from './issueOwnership' import type { KanbanScope } from './kanbanScope' import type { ExtensionToWebview, WebviewToExtension } from './messages' -import { randomBytes } from 'node:crypto' -import * as fs from 'node:fs' -import * as path from 'node:path' import { env, TabInputTerminal, ThemeColor, Uri, ViewColumn, window, workspace } from 'vscode' import { deleteToken, getToken } from '../auth/secrets' import { importPluginIntoFigma } from '../figma/importPlugin' @@ -28,6 +25,7 @@ import { logger } from '../logging/logger' import { annotateMissingSessionIds } from '../sessions/sessionFiles' import { getEffectiveCommitProfilePath, getEffectivePrDiffSummaryProfilePath, getSettings } from '../settings/store' import { webhookCoordinator } from '../webhook/coordinator' +import { makeNonce, webviewHtml, webviewUiRoot } from '../webview/html' import { loadYouTrackIssues } from '../youtrack/issueLoader' import * as handoffFlow from './handlers/handoffFlow' import * as issues from './handlers/issues' @@ -240,7 +238,7 @@ export class KanbanWebviewPanel { this.panel.webview.options = { enableScripts: true, localResourceRoots: [ - Uri.joinPath(this.context.extensionUri, 'dist', 'webview-ui'), + webviewUiRoot(this.context.extensionUri), ], } @@ -347,7 +345,7 @@ export class KanbanWebviewPanel { enableScripts: true, retainContextWhenHidden: true, localResourceRoots: [ - Uri.joinPath(context.extensionUri, 'dist', 'webview-ui'), + webviewUiRoot(context.extensionUri), ], }, ) @@ -1308,56 +1306,14 @@ export class KanbanWebviewPanel { } private buildHtml(): string { - const distRoot = Uri.joinPath(this.context.extensionUri, 'dist', 'webview-ui') - const indexPath = path.join(distRoot.fsPath, 'index.html') - let html = fs.readFileSync(indexPath, 'utf-8') - const nonce = makeNonce() - const cspSource = this.panel.webview.cspSource - - // 重写 src 和 href 的相对路径为 webview URI。vite 产物是固定名(无 hash), - // webview 按 URL 缓存资源——必须按文件 mtime 加 ?v= 版本号,否则 reload 后 - // 仍会跑缓存里的旧 index.js / index.css。 - html = html.replace(/(src|href)="(\/[^"]+|[^"/][^"]*)"/g, (_m, attr, p) => { - const cleaned = p.replace(/^\.?\//, '') - const fileUri = Uri.joinPath(distRoot, cleaned) - let version = '' - try { - version = `?v=${fs.statSync(fileUri.fsPath).mtimeMs}` - } - catch { - // 资源文件不存在则不加版本号 - } - const uri = this.panel.webview.asWebviewUri(fileUri) - return `${attr}="${uri}${version}"` - }) - - // 给所有