✨ feat(vscode): 代码阅读入口、文件树与预览编辑器
This commit is contained in:
1 parent
4d34b8c715
commit
2b823a32ca
24 files changed
+768
-72
No files matched your search
@@ -0,0 +1,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)}`)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user