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}"`
- })
-
- // 给所有