✨ feat(vscode): 代码阅读入口、文件树与预览编辑器
This commit is contained in:
1 parent
4d34b8c715
commit
2b823a32ca
24 files changed
+765
-69
No files matched your search
+83
-2
@@ -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",
|
||||
|
||||
Generated
+23
-6
@@ -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:
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 7v14"/>
|
||||
<path d="M16 12h2"/>
|
||||
<path d="M16 8h2"/>
|
||||
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>
|
||||
<path d="M6 12h2"/>
|
||||
<path d="M6 8h2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 422 B |
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'),
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = {}
|
||||
const registry = new CodeReaderRegistry({
|
||||
get: <T>(key: string): T | undefined => store[key] as T | undefined,
|
||||
update: async (key: string, value: unknown) => {
|
||||
store[key] = value
|
||||
},
|
||||
} as Pick<Memento, 'get' | 'update'>)
|
||||
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('语法包加载失败')
|
||||
})
|
||||
})
|
||||
@@ -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<CodeReaderRegistry, 'languageFor' | 'translatorsFor' | 'selectedTranslator'>,
|
||||
runtime: Pick<TreeSitterRuntime, 'parse'>,
|
||||
): Promise<DocumentMessage> {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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<Preview>()
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createExcludeMatcher, sortEntries } from './fileEntries'
|
||||
|
||||
const dir = (name: string): { name: string, isDirectory: boolean } => ({ name, isDirectory: true })
|
||||
const file = (name: string): { name: string, isDirectory: boolean } => ({ name, isDirectory: false })
|
||||
|
||||
describe('文件树排序', () => {
|
||||
it('目录在前、文件在后,名字不区分大小写', () => {
|
||||
const sorted = sortEntries([file('b.ts'), dir('src'), file('A.ts'), dir('Docs'), file('a.md')])
|
||||
expect(sorted.map(e => e.name)).toEqual(['Docs', 'src', 'a.md', 'A.ts', 'b.ts'])
|
||||
})
|
||||
|
||||
it('名字里的数字按数值比', () => {
|
||||
expect(sortEntries([file('item10.ts'), file('item2.ts'), file('item1.ts')]).map(e => e.name))
|
||||
.toEqual(['item1.ts', 'item2.ts', 'item10.ts'])
|
||||
})
|
||||
|
||||
it('只差大小写的名字次序稳定,不依赖输入顺序', () => {
|
||||
const a = sortEntries([file('readme.md'), file('README.md')]).map(e => e.name)
|
||||
const b = sortEntries([file('README.md'), file('readme.md')]).map(e => e.name)
|
||||
expect(a).toEqual(b)
|
||||
})
|
||||
})
|
||||
|
||||
describe('files.exclude', () => {
|
||||
const none = new Set<string>()
|
||||
|
||||
it('默认的排除项(.git、.DS_Store 等)在任意层级都生效', () => {
|
||||
const excluded = createExcludeMatcher({ '**/.git': true, '**/.DS_Store': true, '**/node_modules': true })
|
||||
expect(excluded('.git', none)).toBe(true)
|
||||
expect(excluded('packages/app/.DS_Store', none)).toBe(true)
|
||||
expect(excluded('node_modules', none)).toBe(true)
|
||||
expect(excluded('src/.gitignore', none)).toBe(false)
|
||||
expect(excluded('src/index.ts', none)).toBe(false)
|
||||
})
|
||||
|
||||
it('值为 false 的规则不排除;没有配置时什么都不排除', () => {
|
||||
expect(createExcludeMatcher({ '**/dist': false })('dist', none)).toBe(false)
|
||||
expect(createExcludeMatcher(undefined)('dist', none)).toBe(false)
|
||||
})
|
||||
|
||||
it('不带 **/ 的规则只匹配工作区根下的路径', () => {
|
||||
const excluded = createExcludeMatcher({ build: true })
|
||||
expect(excluded('build', none)).toBe(true)
|
||||
expect(excluded('src/build', none)).toBe(false)
|
||||
})
|
||||
|
||||
it('* 也匹配点开头的名字,花括号展开可用', () => {
|
||||
const excluded = createExcludeMatcher({ '**/*.{log,tmp}': true })
|
||||
expect(excluded('.debug.log', none)).toBe(true)
|
||||
expect(excluded('.github/x.tmp', none)).toBe(true)
|
||||
expect(excluded('x.ts', none)).toBe(false)
|
||||
})
|
||||
|
||||
it('when 条件:有同名的兄弟文件才排除', () => {
|
||||
const excluded = createExcludeMatcher({ '**/*.js': { when: '$(basename).ts' } })
|
||||
expect(excluded('src/app.js', new Set(['app.js', 'app.ts']))).toBe(true)
|
||||
expect(excluded('src/tool.js', new Set(['tool.js', 'app.ts']))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Minimatch } from 'minimatch'
|
||||
|
||||
export interface DirEntry { name: string, isDirectory: boolean }
|
||||
|
||||
const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true })
|
||||
|
||||
/** 目录在前、文件在后;同类按名字不区分大小写排(数字按数值比,和自带资源管理器一致),只差大小写时再按原样比,保证次序稳定。 */
|
||||
export function sortEntries<T extends DirEntry>(entries: T[]): T[] {
|
||||
return [...entries].sort((a, b) =>
|
||||
Number(b.isDirectory) - Number(a.isDirectory)
|
||||
|| collator.compare(a.name, b.name)
|
||||
|| (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
||||
}
|
||||
|
||||
/** `files.exclude` 的值:true 排除;false 不排除;`{ when }` 只在有对应的兄弟文件时排除。 */
|
||||
export type ExcludeSetting = Record<string, boolean | { when: string } | null | undefined>
|
||||
|
||||
/**
|
||||
* @param relativePath 相对工作区文件夹的路径,用 `/` 分隔
|
||||
* @param siblingNames 同一目录下的全部名字(算 `when` 条件用)
|
||||
*/
|
||||
export type ExcludeMatcher = (relativePath: string, siblingNames: ReadonlySet<string>) => boolean
|
||||
|
||||
/** 去掉最后一个扩展名:`when` 里的 `$(basename)` 指的就是它。 */
|
||||
function stem(name: string): string {
|
||||
const dot = name.lastIndexOf('.')
|
||||
return dot > 0 ? name.slice(0, dot) : name
|
||||
}
|
||||
|
||||
/** 按 `files.exclude` 的写法判断路径是否被排除:glob 相对工作区文件夹匹配,`*` 也匹配点开头的名字。 */
|
||||
export function createExcludeMatcher(setting: ExcludeSetting | undefined): ExcludeMatcher {
|
||||
const rules = Object.entries(setting ?? {}).flatMap(([pattern, value]) => value
|
||||
? [{ glob: new Minimatch(pattern, { dot: true }), when: typeof value === 'object' ? value.when : undefined }]
|
||||
: [])
|
||||
return (relativePath, siblingNames) => rules.some(({ glob, when }) => {
|
||||
if (!glob.match(relativePath))
|
||||
return false
|
||||
if (when === undefined)
|
||||
return true
|
||||
const name = relativePath.slice(relativePath.lastIndexOf('/') + 1)
|
||||
return siblingNames.has(when.replace('$(basename)', stem(name)))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Event, TreeDataProvider, WorkspaceFolder } from 'vscode'
|
||||
import type { ExcludeSetting } from './fileEntries'
|
||||
import * as path from 'node:path'
|
||||
import { EventEmitter, FileType, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri, workspace } from 'vscode'
|
||||
import { createExcludeMatcher, sortEntries } from './fileEntries'
|
||||
|
||||
export interface FileNode {
|
||||
uri: Uri
|
||||
folder: WorkspaceFolder
|
||||
isDirectory: boolean
|
||||
isRoot: boolean
|
||||
}
|
||||
|
||||
/** 批量改文件(git checkout、构建输出)时合并成一次刷新。 */
|
||||
const REFRESH_DELAY_MS = 300
|
||||
|
||||
/**
|
||||
* 侧栏的工作区文件树:每个工作区文件夹一个根节点,只看和打开。
|
||||
* 节点用 resourceUri,图标、git 改动颜色都由 VS Code 按文件图标主题和 SCM 装饰自动加上。
|
||||
*/
|
||||
export class FileTreeProvider implements TreeDataProvider<FileNode> {
|
||||
private readonly changed = new EventEmitter<void>()
|
||||
readonly onDidChangeTreeData: Event<void> = this.changed.event
|
||||
private readonly disposables: { dispose: () => unknown }[]
|
||||
private timer: NodeJS.Timeout | undefined
|
||||
|
||||
/** @param openCommand 点文件时执行的命令,参数是文件 Uri */
|
||||
constructor(private readonly openCommand: string) {
|
||||
// 只关心增删:文件内容变化不影响树的结构
|
||||
const watcher = workspace.createFileSystemWatcher('**/*', false, true, false)
|
||||
this.disposables = [
|
||||
this.changed,
|
||||
watcher,
|
||||
watcher.onDidCreate(() => this.scheduleRefresh()),
|
||||
watcher.onDidDelete(() => this.scheduleRefresh()),
|
||||
workspace.onDidChangeWorkspaceFolders(() => this.refresh()),
|
||||
workspace.onDidChangeConfiguration((e) => {
|
||||
if (e.affectsConfiguration('files.exclude'))
|
||||
this.refresh()
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = undefined
|
||||
this.changed.fire()
|
||||
}
|
||||
|
||||
getTreeItem(node: FileNode): TreeItem {
|
||||
if (!node.isDirectory) {
|
||||
const item = new TreeItem(node.uri, TreeItemCollapsibleState.None)
|
||||
item.iconPath = ThemeIcon.File
|
||||
item.command = { command: this.openCommand, title: '打开', arguments: [node.uri] }
|
||||
return item
|
||||
}
|
||||
const item = new TreeItem(node.uri, node.isRoot ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed)
|
||||
item.iconPath = ThemeIcon.Folder
|
||||
if (node.isRoot)
|
||||
item.label = node.folder.name
|
||||
return item
|
||||
}
|
||||
|
||||
async getChildren(node?: FileNode): Promise<FileNode[]> {
|
||||
if (!node)
|
||||
return (workspace.workspaceFolders ?? []).map(folder => ({ uri: folder.uri, folder, isDirectory: true, isRoot: true }))
|
||||
|
||||
let entries: [string, FileType][]
|
||||
try {
|
||||
entries = await workspace.fs.readDirectory(node.uri)
|
||||
}
|
||||
catch {
|
||||
return [] // 目录刚被删掉或没有权限
|
||||
}
|
||||
const excluded = createExcludeMatcher(workspace.getConfiguration('files', node.folder).get<ExcludeSetting>('exclude'))
|
||||
const names = new Set(entries.map(([name]) => name))
|
||||
const dir = path.posix.relative(node.folder.uri.path, node.uri.path)
|
||||
return sortEntries(entries.map(([name, type]) => ({ name, isDirectory: (type & FileType.Directory) !== 0 })))
|
||||
.filter(entry => !excluded(dir ? `${dir}/${entry.name}` : entry.name, names))
|
||||
.map(entry => ({ uri: Uri.joinPath(node.uri, entry.name), folder: node.folder, isDirectory: entry.isDirectory, isRoot: false }))
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
clearTimeout(this.timer)
|
||||
for (const disposable of this.disposables)
|
||||
disposable.dispose()
|
||||
}
|
||||
|
||||
private scheduleRefresh(): void {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => this.refresh(), REFRESH_DELAY_MS)
|
||||
}
|
||||
}
|
||||
@@ -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<string | undefined> {
|
||||
try {
|
||||
return (await workspace.openTextDocument(uri)).languageId
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** 侧栏点文件:语言受支持就用预览打开,否则用普通编辑器。 */
|
||||
async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise<void> {
|
||||
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
|
||||
}
|
||||
+10
-1
@@ -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<never> {
|
||||
}
|
||||
}
|
||||
|
||||
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 {}
|
||||
@@ -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}"`
|
||||
})
|
||||
|
||||
// 给所有 <script> 标签注入 nonce
|
||||
html = html.replace(/<script(\s[^>]*)?>/g, (_m, attrs) => {
|
||||
const a = attrs ?? ''
|
||||
return `<script nonce="${nonce}"${a}>`
|
||||
})
|
||||
|
||||
// 注入 CSP
|
||||
const csp = `default-src 'none'; img-src ${cspSource} data:; style-src ${cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}'; font-src ${cspSource};`
|
||||
html = html.replace(
|
||||
/<head>/,
|
||||
`<head>\n <meta http-equiv="Content-Security-Policy" content="${csp}">`,
|
||||
)
|
||||
|
||||
const html = webviewHtml(this.panel.webview, this.context.extensionUri, 'index.html', nonce)
|
||||
const snapshot = parseKanbanSnapshot(this.context.workspaceState.get(KANBAN_SNAPSHOT_KEY))
|
||||
if (snapshot) {
|
||||
html = html.replace(
|
||||
if (!snapshot)
|
||||
return html
|
||||
return html.replace(
|
||||
'</body>',
|
||||
` ${snapshotScriptTag(nonce, snapshot)}\n </body>`,
|
||||
)
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
}
|
||||
|
||||
// internal: handler 模块访问
|
||||
export function makeNonce(): string {
|
||||
return randomBytes(16).toString('base64').replace(/[+/=]/g, '')
|
||||
}
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
import { logger } from '../../logging/logger'
|
||||
import { annotateMissingSessionIds, defaultCodexSessionsDir } from '../../sessions/sessionFiles'
|
||||
import { getSettings } from '../../settings/store'
|
||||
import { makeNonce } from '../KanbanPanel'
|
||||
import { makeNonce } from '../../webview/html'
|
||||
import { cleanupFeatureBranch } from './branchCleanup'
|
||||
import {
|
||||
canAcceptHandoff,
|
||||
|
||||
@@ -36,9 +36,9 @@ import { mergeStateJsonComment } from '../../gitea/stateJson'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { getEffectivePrDiffSummaryProfilePath, getSettings } from '../../settings/store'
|
||||
import { webhookCoordinator } from '../../webhook/coordinator'
|
||||
import { makeNonce } from '../../webview/html'
|
||||
import { pickRandomIssueColor, themeColorIdToIconUri } from '../issueColor'
|
||||
import { evaluateLockedReason } from '../issueLock'
|
||||
import { makeNonce } from '../KanbanPanel'
|
||||
import { cleanupFeatureBranch } from './branchCleanup'
|
||||
import { clearSessionConfirmText, clearSessionDoneText, SESSION_KINDS } from './sessionKinds'
|
||||
import * as sessions from './sessions'
|
||||
|
||||
@@ -24,7 +24,7 @@ import { readStateJsonComment } from '../../gitea/stateJson'
|
||||
import { readFileSummaries, readSummarySession, writeFileSummary, writeSummarySession } from '../../sessions/prFileSummaryStore'
|
||||
import { readConfirmedSigs, writeConfirmedSigs } from '../../sessions/prReviewStore'
|
||||
import { getEffectivePrDiffSummaryProfilePath } from '../../settings/store'
|
||||
import { makeNonce } from '../KanbanPanel'
|
||||
import { makeNonce } from '../../webview/html'
|
||||
|
||||
/**
|
||||
* 解析当前工作区的 Gitea 仓库与 token。三者任一缺失返回 undefined,
|
||||
|
||||
@@ -22,9 +22,9 @@ import { logger } from '../../logging/logger'
|
||||
import { claudeSessionFileExists, codexSessionFileExists, defaultCodexSessionsDir } from '../../sessions/sessionFiles'
|
||||
import { getSettings } from '../../settings/store'
|
||||
import { webhookCoordinator } from '../../webhook/coordinator'
|
||||
import { makeNonce } from '../../webview/html'
|
||||
import { decideAutoReviewPath } from '../autoReviewDecision'
|
||||
import { pickBrainstormContinuePath } from '../brainstormContinue'
|
||||
import { makeNonce } from '../KanbanPanel'
|
||||
import { cleanupFeatureBranch } from './branchCleanup'
|
||||
import { planResumeCwd, resumeTerminalName } from './resumeTarget'
|
||||
import { submitToSessionTerminal } from './terminals'
|
||||
|
||||
@@ -15,8 +15,8 @@ import { logger } from '../../logging/logger'
|
||||
import { readProfiles, writeProfiles } from '../../profiles/store'
|
||||
import { getEffectiveCommitProfilePath, getEffectivePrDiffSummaryProfilePath, getSettings, saveSettings } from '../../settings/store'
|
||||
import { webhookCoordinator } from '../../webhook/coordinator'
|
||||
import { makeNonce } from '../../webview/html'
|
||||
import { youtrackHost } from '../../youtrack/issueLoader'
|
||||
import { makeNonce } from '../KanbanPanel'
|
||||
|
||||
export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
|
||||
host: string
|
||||
|
||||
@@ -10,7 +10,7 @@ import { findEnvFiles, lockEnvFiles, unlockEnvFiles } from '../../files/envLock'
|
||||
import { missingGitIdentityKeys, readGitIdentity } from '../../git/identity'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { getEffectiveCommitProfilePath } from '../../settings/store'
|
||||
import { makeNonce } from '../KanbanPanel'
|
||||
import { makeNonce } from '../../webview/html'
|
||||
import { dismiss, toast } from './handoffFlow'
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '../../git/worktreeHooks'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { getSettings } from '../../settings/store'
|
||||
import { makeNonce } from '../KanbanPanel'
|
||||
import { makeNonce } from '../../webview/html'
|
||||
import { cleanupFeatureBranch } from './branchCleanup'
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,11 +14,11 @@ import { ThemeIcon, window, workspace } from 'vscode'
|
||||
import { getYouTrackToken } from '../../auth/secrets'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { getSettings } from '../../settings/store'
|
||||
import { makeNonce } from '../../webview/html'
|
||||
import { applyCommand, listIssues, listProjects, resolvedStateCommand } from '../../youtrack/api'
|
||||
import { readImportedIds, writeImportedIds } from '../../youtrack/importStore'
|
||||
import { youtrackHost } from '../../youtrack/issueLoader'
|
||||
import { mergeStateComment } from '../../youtrack/stateComment'
|
||||
import { makeNonce } from '../KanbanPanel'
|
||||
|
||||
function toast(panel: KanbanWebviewPanel, level: 'info' | 'success' | 'error', message: string): void {
|
||||
panel.postMessage({ type: 'toast/show', id: makeNonce(), level, message, dismissOnTimer: 6000 })
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Webview } from 'vscode'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import * as fs from 'node:fs'
|
||||
import { Uri } from 'vscode'
|
||||
|
||||
export function makeNonce(): string {
|
||||
return randomBytes(16).toString('base64').replace(/[+/=]/g, '')
|
||||
}
|
||||
|
||||
/** vite 产物目录,也是 webview 唯一放行的本地资源目录(localResourceRoots)。 */
|
||||
export function webviewUiRoot(extensionUri: Uri): Uri {
|
||||
return Uri.joinPath(extensionUri, 'dist', 'webview-ui')
|
||||
}
|
||||
|
||||
/**
|
||||
* 读 dist/webview-ui 下的一个入口页面,改成能在 webview 里跑的样子:
|
||||
* 资源路径改写成 webview URI、每个 `<script>` 带上 nonce、注入只放行这个 nonce 的 CSP。
|
||||
* entry 是入口 html 文件名(例如 'index.html');调用方还要往页面里追加别的 `<script>` 时,把同一个 nonce 传进来。
|
||||
*/
|
||||
export function webviewHtml(webview: Webview, extensionUri: Uri, entry: string, nonce = makeNonce()): string {
|
||||
const distRoot = webviewUiRoot(extensionUri)
|
||||
let html = fs.readFileSync(Uri.joinPath(distRoot, entry).fsPath, 'utf-8')
|
||||
|
||||
// vite 产物的入口是固定名(无 hash),webview 按 URL 缓存资源——必须按文件 mtime 加 ?v= 版本号,
|
||||
// 否则 reload 后仍会跑缓存里的旧入口脚本 / 样式
|
||||
html = html.replace(/(src|href)="(\/[^"]+|[^"/][^"]*)"/g, (_m, attr, p) => {
|
||||
const fileUri = Uri.joinPath(distRoot, p.replace(/^\.?\//, ''))
|
||||
let version = ''
|
||||
try {
|
||||
version = `?v=${fs.statSync(fileUri.fsPath).mtimeMs}`
|
||||
}
|
||||
catch {
|
||||
// 资源文件不存在则不加版本号
|
||||
}
|
||||
return `${attr}="${webview.asWebviewUri(fileUri)}${version}"`
|
||||
})
|
||||
|
||||
html = html.replace(/<script(\s[^>]*)?>/g, (_m, attrs) => `<script nonce="${nonce}"${attrs ?? ''}>`)
|
||||
|
||||
const cspSource = webview.cspSource
|
||||
const csp = `default-src 'none'; img-src ${cspSource} data:; style-src ${cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}'; font-src ${cspSource};`
|
||||
return html.replace(
|
||||
/<head>/,
|
||||
`<head>\n <meta http-equiv="Content-Security-Policy" content="${csp}">`,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user