✨ feat(vscode): 代码阅读预览界面

Vite 加 code-reader.html 第二入口;代码块树(层级竖线、折叠、种类标签配色)、译文 / 源码按词性着色、块内直接编辑(防抖回写整行、Esc 回预览)、翻译下拉、解析失败提示;看板与代码阅读共用带类型的 acquireVsCodeApi 包装和 ErrorBoundary。
This commit is contained in:
cruldra committed 2026-09-24 21:01:34 +08:00
1 parent cab5ac0ebc
commit af56985d9a
16 files changed
+775 -74

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>代码阅读</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/codeReader/main.tsx"></script>
</body>
</html>
+81
View File
@@ -0,0 +1,81 @@
import type { ReactElement } from 'react'
import type { BlockTreeState } from './BlockTree'
import type { DocumentMessage } from './types'
import type { ViewState } from './vscode'
import { TriangleAlert } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { SelectMenu } from '@/components/ui/select-menu'
import { BlockLevel } from './BlockTree'
import { loadViewState, onMessage, postMessage, saveViewState } from './vscode'
function toggled(ids: string[], id: string): string[] {
return ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id]
}
export function App(): ReactElement | null {
const [doc, setDoc] = useState<DocumentMessage | null>(null)
const [view, setView] = useState<ViewState>(loadViewState)
const [focusId, setFocusId] = useState<string | null>(null)
useEffect(() => {
const off = onMessage((msg) => {
if (msg.type === 'document')
setDoc(msg)
})
postMessage({ type: 'ready' })
return off
}, [])
useEffect(() => {
saveViewState(view)
}, [view])
const onFocused = useCallback(() => setFocusId(null), [])
const tree = useMemo((): BlockTreeState | null => doc && {
version: doc.version,
editing: new Set(view.editing),
collapsed: new Set(view.collapsed),
focusId,
onFocused,
onToggleEdit: (id) => {
setFocusId(view.editing.includes(id) ? null : id)
setView(prev => ({ ...prev, editing: toggled(prev.editing, id) }))
},
onToggleFold: id => setView(prev => ({ ...prev, collapsed: toggled(prev.collapsed, id) })),
}, [doc, view, focusId, onFocused])
if (!doc || !tree)
return null
return (
<div className="mx-auto box-content max-w-[880px] px-6 py-5">
{doc.translators.length > 1 && (
<div className="mb-3 flex items-center justify-end gap-2">
<span className="text-xs text-[var(--vscode-descriptionForeground)]">翻译</span>
<SelectMenu
value={doc.translatorId}
options={doc.translators.map(t => ({ value: t.id, label: t.label }))}
onChange={id => postMessage({ type: 'selectTranslator', id })}
title="选择翻译方式"
className="w-40 text-left"
/>
</div>
)}
{doc.error !== undefined && (
<div className="mb-3 flex items-start gap-2 rounded-lg border border-[var(--vscode-inputValidation-errorBorder)] bg-[var(--vscode-inputValidation-errorBackground)] px-3 py-2">
<TriangleAlert size={16} strokeWidth={1.8} className="mt-px shrink-0 text-[var(--vscode-errorForeground)]" />
<div className="min-w-0">
<div className="break-words">
解析失败:
{doc.error}
</div>
<div className="mt-0.5 text-xs text-[var(--vscode-descriptionForeground)]">点编辑器右上角的「编辑」查看源码。</div>
</div>
</div>
)}
{doc.blocks.length > 0
? <BlockLevel blocks={doc.blocks} tree={tree} />
: doc.error === undefined && <div className="text-[var(--vscode-descriptionForeground)]">这个文件里没有代码。</div>}
</div>
)
}
@@ -0,0 +1,118 @@
import type { ReactElement } from 'react'
import type { BlockKind, BlockView } from './types'
import { ChevronDown, ChevronRight, Eye, Pencil } from 'lucide-react'
import { Fragment } from 'react'
import { cn } from '@/lib/utils'
import { CodeEditor } from './CodeEditor'
import { SegmentText } from './SegmentText'
const KIND_LABEL: Record<BlockKind, string> = {
import: '导入',
function: '函数',
class: '类',
type: '类型',
field: '字段',
const: '常量',
variable: '变量',
if: '条件',
else: '否则',
loop: '循环',
try: '尝试',
catch: '捕获',
finally: '最终',
return: '返回',
statement: '语句',
comment: '注释',
}
export interface BlockTreeState {
version: number
editing: ReadonlySet<string>
collapsed: ReadonlySet<string>
/** 刚点了「编辑」、要把光标放进去的块 */
focusId: string | null
onFocused: () => void
onToggleEdit: (id: string) => void
onToggleFold: (id: string) => void
}
/** 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。 */
export function BlockLevel({ blocks, tree }: { blocks: BlockView[], tree: BlockTreeState }): ReactElement {
return (
<div className="flex gap-2">
<div className="flex w-3 shrink-0 justify-center">
<div className="w-px bg-[var(--vscode-editorIndentGuide-background1)]" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
{blocks.map(block => (
<Fragment key={block.id}>
<BlockCard block={block} tree={tree} />
{block.children.length > 0 && !tree.collapsed.has(block.id) && (
<BlockLevel blocks={block.children} tree={tree} />
)}
</Fragment>
))}
</div>
</div>
)
}
/** 一块:折叠箭头、种类标签、译文或源码、右边的「编辑 / 预览」切换。 */
function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement {
const editing = tree.editing.has(block.id)
const collapsed = tree.collapsed.has(block.id)
const hasChildren = block.children.length > 0
const Chevron = collapsed ? ChevronRight : ChevronDown
return (
<div
className="cr-block flex items-start gap-2.5 rounded-lg py-2 pr-2.5 pl-2"
data-kind={block.kind}
data-editing={editing ? '' : undefined}
>
{hasChildren
? (
<button
type="button"
className="flex h-5 w-3.5 shrink-0 items-center justify-center rounded-sm text-[var(--vscode-descriptionForeground)] hover:text-[var(--vscode-foreground)]"
aria-label={collapsed ? '展开子块' : '折叠子块'}
aria-expanded={!collapsed}
onClick={() => tree.onToggleFold(block.id)}
>
<Chevron size={14} strokeWidth={1.8} />
</button>
)
: <span className="w-3.5 shrink-0" />}
<span className="cr-badge flex h-5 w-11 shrink-0 items-center justify-center rounded text-[11px] leading-[14px] font-medium">
{KIND_LABEL[block.kind]}
</span>
<div className="min-w-0 flex-1">
{editing
? (
<CodeEditor
block={block}
version={tree.version}
autoFocus={tree.focusId === block.id}
onAutoFocused={tree.onFocused}
onExit={() => tree.onToggleEdit(block.id)}
/>
)
: (
<p className="cr-code">
<SegmentText segments={block.translation} />
</p>
)}
</div>
<button
type="button"
className={cn(
'flex shrink-0 items-center gap-1 rounded px-2 py-0.5 text-xs leading-4 text-[var(--vscode-descriptionForeground)] hover:bg-[var(--vscode-toolbar-hoverBackground)]',
editing && 'bg-[var(--vscode-toolbar-activeBackground)]',
)}
onClick={() => tree.onToggleEdit(block.id)}
>
{editing ? <Eye size={13} strokeWidth={1.8} /> : <Pencil size={13} strokeWidth={1.8} />}
{editing ? '预览' : '编辑'}
</button>
</div>
)
}
@@ -0,0 +1,129 @@
import type { ChangeEvent, KeyboardEvent, ReactElement } from 'react'
import type { BlockView } from './types'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { recolor } from './segments'
import { SegmentText } from './SegmentText'
import { postMessage } from './vscode'
const EDIT_DEBOUNCE_MS = 200
interface CodeEditorProps {
block: BlockView
/** 文档版本:每来一份新文档都要对一次输入框内容,哪怕这一块的代码没变 */
version: number
autoFocus: boolean
onAutoFocused: () => void
onExit: () => void
}
/** 一块源码的编辑状态:着色层显示,透明输入框接收输入,改动防抖后整行替换回文件。 */
export function CodeEditor({ block, version, autoFocus, onAutoFocused, onExit }: CodeEditorProps): ReactElement {
const [text, setText] = useState(block.code)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const blockRef = useRef(block)
blockRef.current = block
const textRef = useRef(text)
textRef.current = text
/** 本地改过、还没在新文档里看到的文字;有它时新文档不能覆盖有焦点的输入框 */
const unconfirmed = useRef<string | null>(null)
/** 最近发出的文字在文件里占几行:新文档到之前,下一次替换要盖住这么多行,而不是旧文档里的行数 */
const sentLineCount = useRef<number | null>(null)
const timer = useRef<number | undefined>(undefined)
const unsent = useRef(false)
const restoreSelection = useRef<[number, number] | null>(null)
function flush(): void {
window.clearTimeout(timer.current)
if (!unsent.current)
return
unsent.current = false
const { startLine, endLine } = blockRef.current
const value = textRef.current
postMessage({
type: 'edit',
startLine,
endLine: sentLineCount.current === null ? endLine : startLine + sentLineCount.current - 1,
text: value,
})
sentLineCount.current = value.split('\n').length
}
useEffect(() => {
const el = textareaRef.current
const focused = el !== null && document.activeElement === el
if (focused && unconfirmed.current !== null && unconfirmed.current !== block.code)
return
unconfirmed.current = null
sentLineCount.current = null
if (block.code === textRef.current)
return
if (focused)
restoreSelection.current = [el.selectionStart, el.selectionEnd]
setText(block.code)
}, [version, block.code])
useLayoutEffect(() => {
const el = textareaRef.current
const selection = restoreSelection.current
if (!el || !selection)
return
restoreSelection.current = null
el.setSelectionRange(Math.min(selection[0], text.length), Math.min(selection[1], text.length))
}, [text])
useEffect(() => {
const el = textareaRef.current
if (!autoFocus || !el)
return
el.focus()
el.setSelectionRange(el.value.length, el.value.length)
onAutoFocused()
}, [autoFocus, onAutoFocused])
// 卸载(切回预览、块消失)前把没发出去的改动发掉
useEffect(() => flush, [])
function onChange(e: ChangeEvent<HTMLTextAreaElement>): void {
const value = e.target.value
setText(value)
unconfirmed.current = value
unsent.current = true
window.clearTimeout(timer.current)
timer.current = window.setTimeout(flush, EDIT_DEBOUNCE_MS)
}
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>): void {
if (e.key === 'Escape') {
e.preventDefault()
flush()
onExit()
}
// ⌘S 由 VS Code 接管保存:先把还在防抖里的改动发出去,免得存的是旧内容
else if (e.key === 's' && (e.metaKey || e.ctrlKey)) {
flush()
}
}
return (
<div className="cr-editor">
<textarea
ref={textareaRef}
className="cr-code"
value={text}
rows={1}
spellCheck={false}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
aria-label="源码"
onChange={onChange}
onKeyDown={onKeyDown}
onBlur={flush}
/>
<pre className="cr-code" aria-hidden="true">
<SegmentText segments={recolor(block.codeTokens, block.code, text)} />
{text === '' || text.endsWith('\n') ? ' ' : ''}
</pre>
</div>
)
}
@@ -0,0 +1,13 @@
import type { ReactElement } from 'react'
import type { Segment } from './types'
/** 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换。 */
export function SegmentText({ segments }: { segments: Segment[] }): ReactElement {
return (
<>
{segments.map((segment, index) => (
<span key={index} className={`cr-role-${segment.role}`}>{segment.text}</span>
))}
</>
)
}
@@ -0,0 +1,133 @@
@import "tailwindcss";
/* 词性与块种类的配色:暗色(含高对比)取 VS Code Dark+,亮色取 Light+。其余界面颜色一律用 --vscode-* 变量。 */
body {
--cr-keyword: #c586c0;
--cr-storage: #569cd6;
--cr-variable: #9cdcfe;
--cr-function: #dcdcaa;
--cr-type: #4ec9b0;
--cr-string: #ce9178;
--cr-number: #b5cea8;
--cr-comment: #6a9955;
--cr-kind-import: #c586c0;
--cr-kind-function: #dcdcaa;
--cr-kind-type: #4ec9b0;
--cr-kind-data: #9cdcfe;
--cr-kind-branch: #ce9178;
--cr-kind-loop: #8f9cff;
--cr-kind-error: #f48771;
--cr-kind-return: #89d185;
--cr-kind-statement: #9d9d9d;
--cr-kind-comment: #6a9955;
margin: 0;
font-family: var(--vscode-font-family, system-ui, sans-serif);
font-size: var(--vscode-font-size, 13px);
color: var(--vscode-foreground);
background: var(--vscode-editor-background);
}
body.vscode-light,
body.vscode-high-contrast-light {
--cr-keyword: #af00db;
--cr-storage: #0000ff;
--cr-variable: #001080;
--cr-function: #795e26;
--cr-type: #267f99;
--cr-string: #a31515;
--cr-number: #098658;
--cr-comment: #008000;
--cr-kind-import: #af00db;
--cr-kind-function: #795e26;
--cr-kind-type: #267f99;
--cr-kind-data: #001080;
--cr-kind-branch: #a31515;
--cr-kind-loop: #4f56d6;
--cr-kind-error: #b5452f;
--cr-kind-return: #388a34;
--cr-kind-statement: #6f6f6f;
--cr-kind-comment: #008000;
}
/* 种类颜色:同一类语义共用一个颜色,标签文字区分具体是哪种 */
.cr-block[data-kind="import"] { --kind: var(--cr-kind-import); }
.cr-block[data-kind="function"] { --kind: var(--cr-kind-function); }
.cr-block:is([data-kind="class"], [data-kind="type"]) { --kind: var(--cr-kind-type); }
.cr-block:is([data-kind="field"], [data-kind="const"], [data-kind="variable"]) { --kind: var(--cr-kind-data); }
.cr-block:is([data-kind="if"], [data-kind="else"]) { --kind: var(--cr-kind-branch); }
.cr-block[data-kind="loop"] { --kind: var(--cr-kind-loop); }
.cr-block:is([data-kind="try"], [data-kind="catch"], [data-kind="finally"]) { --kind: var(--cr-kind-error); }
.cr-block[data-kind="return"] { --kind: var(--cr-kind-return); }
.cr-block[data-kind="statement"] { --kind: var(--cr-kind-statement); }
.cr-block[data-kind="comment"] { --kind: var(--cr-kind-comment); }
.cr-block {
background: color-mix(in srgb, var(--kind) 7%, transparent);
border: 1px solid color-mix(in srgb, var(--kind) 35%, transparent);
}
.cr-block[data-kind="comment"] {
border-style: dashed;
}
.cr-block[data-editing] {
background: var(--vscode-editor-background);
border-color: var(--vscode-focusBorder);
}
.cr-badge {
color: var(--kind);
background: color-mix(in srgb, var(--kind) 18%, transparent);
}
/* 译文和源码共用的文字排版;源码编辑时输入框和着色层都用它,两层逐字对齐 */
.cr-code {
margin: 0;
padding: 0;
border: 0;
font-family: var(--vscode-editor-font-family, monospace), "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
font-size: var(--vscode-editor-font-size, 12.5px);
font-weight: var(--vscode-editor-font-weight, normal);
line-height: 20px;
letter-spacing: 0;
tab-size: 4;
white-space: pre-wrap;
overflow-wrap: break-word;
color: var(--vscode-editor-foreground);
}
.cr-role-keyword { color: var(--cr-keyword); }
.cr-role-storage,
.cr-role-operator { color: var(--cr-storage); }
.cr-role-variable { color: var(--cr-variable); }
.cr-role-function { color: var(--cr-function); }
.cr-role-type { color: var(--cr-type); }
.cr-role-string { color: var(--cr-string); }
.cr-role-number,
.cr-role-literal { color: var(--cr-number); }
.cr-role-comment { color: var(--cr-comment); }
/* 编辑:透明输入框垫在底下接收输入和选区,着色层盖在上面只负责显示,高度由着色层撑开 */
.cr-editor {
position: relative;
}
.cr-editor > textarea {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
resize: none;
overflow: hidden;
outline: none;
background: transparent;
color: transparent;
caret-color: var(--vscode-editorCursor-foreground);
}
.cr-editor > textarea::selection {
color: transparent;
background: var(--vscode-editor-selectionBackground);
}
.cr-editor > pre {
position: relative;
pointer-events: none;
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { ErrorBoundary } from '@/components/ErrorBoundary'
import { App } from './App'
import './codeReader.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>,
)
@@ -0,0 +1,67 @@
import type { Segment } from './types'
import { describe, expect, it } from 'vitest'
import { recolor, sliceSegments } from './segments'
const tokens: Segment[] = [
{ text: 'const', role: 'storage' },
{ text: ' ', role: 'plain' },
{ text: 'uris', role: 'variable' },
{ text: ' = ', role: 'plain' },
{ text: '42', role: 'number' },
]
const code = 'const uris = 42'
function join(segments: Segment[]): string {
return segments.map(s => s.text).join('')
}
describe('sliceSegments', () => {
it('跨段切出一段时每个字保留原词性', () => {
expect(sliceSegments(tokens, 3, 8)).toEqual([
{ text: 'st', role: 'storage' },
{ text: ' ', role: 'plain' },
{ text: 'ur', role: 'variable' },
])
})
it('空区间返回空数组', () => {
expect(sliceSegments(tokens, 4, 4)).toEqual([])
})
})
describe('recolor', () => {
it('文字没变时原样返回着色', () => {
expect(recolor(tokens, code, code)).toBe(tokens)
})
it('中间插入的字不着色,前后沿用原词性', () => {
const text = 'const allUris = 42'
const result = recolor(tokens, code, text)
expect(join(result)).toBe(text)
expect(result).toEqual([
{ text: 'const', role: 'storage' },
{ text: ' ', role: 'plain' },
{ text: 'allU', role: 'plain' },
{ text: 'ris', role: 'variable' },
{ text: ' = ', role: 'plain' },
{ text: '42', role: 'number' },
])
})
it('删掉结尾时只剩前缀的着色', () => {
expect(recolor(tokens, code, 'const ur')).toEqual([
{ text: 'const', role: 'storage' },
{ text: ' ', role: 'plain' },
{ text: 'ur', role: 'variable' },
])
})
it('重复字符不会让前后缀重叠', () => {
const result = recolor([{ text: 'aa', role: 'variable' }], 'aa', 'aaa')
expect(join(result)).toBe('aaa')
})
it('清空后没有任何段', () => {
expect(recolor(tokens, code, '')).toEqual([])
})
})
@@ -0,0 +1,39 @@
import type { Segment } from './types'
/** 取 segments 拼起来的文字里 [from, to) 这一段,每个字保留原来的词性。 */
export function sliceSegments(segments: Segment[], from: number, to: number): Segment[] {
const out: Segment[] = []
let offset = 0
for (const segment of segments) {
const start = Math.max(from, offset)
const end = Math.min(to, offset + segment.text.length)
if (start < end)
out.push({ text: segment.text.slice(start - offset, end - offset), role: segment.role })
offset += segment.text.length
if (offset >= to)
break
}
return out
}
/**
* 给输入框里改过的文字上色:`tokens` 是 `code` 的着色,和 `code` 相同的开头、结尾沿用原来的词性,
* 中间改动的部分先不着色,等扩展端按新代码着色的文档到了再换成准确的。
*/
export function recolor(tokens: Segment[], code: string, text: string): Segment[] {
if (text === code)
return tokens
const shorter = Math.min(code.length, text.length)
let head = 0
while (head < shorter && code[head] === text[head])
head++
let tail = 0
while (tail < shorter - head && code[code.length - 1 - tail] === text[text.length - 1 - tail])
tail++
const changed = text.slice(head, text.length - tail)
return [
...sliceSegments(tokens, 0, head),
...(changed ? [{ text: changed, role: 'plain' as const }] : []),
...sliceSegments(tokens, code.length - tail, code.length),
]
}
+51
View File
@@ -0,0 +1,51 @@
/**
* 代码阅读预览的视图数据与消息,与扩展端 `src/codeReader/` 里的同名类型同构,两边手动保持一致。
*/
export type BlockKind
= | 'import' | 'function' | 'class' | 'type' | 'field' | 'const' | 'variable'
| 'if' | 'else' | 'loop' | 'try' | 'catch' | 'finally' | 'return' | 'statement' | 'comment'
/** 着色用的词性,源码着色和译文着色共用。 */
export type TokenRole
= | 'keyword' // 流程关键字:import from export if else for return try catch await …
| 'storage' // 声明关键字与字面量关键字:const function async class interface new true false null …
| 'operator' // 被翻成文字的运算符:全等 与 或 非
| 'variable' | 'function' | 'type' | 'string' | 'number' | 'literal' | 'comment' | 'plain'
export interface Segment { text: string, role: TokenRole }
export interface BlockView {
/** 在树里的位置,例如 '6.1.0' */
id: string
kind: BlockKind
/** header 的起止行,0 起 */
startLine: number
endLine: number
/** header 源码,去掉共同缩进 */
code: string
/** 源码着色(拼起来等于 code) */
codeTokens: Segment[]
translation: Segment[]
children: BlockView[]
}
export interface TranslatorOption { id: string, label: string }
export interface DocumentMessage {
type: 'document'
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,22 @@
/** 代码阅读预览与扩展之间的收发,以及按块 id 记的视图状态。 */
import type { ExtensionToWebview, WebviewToExtension } from './types'
import { createVsCodeBridge } from '@/lib/vscodeBridge'
/** 哪些块停在「编辑」、哪些块折叠了。块 id 是它在树里的位置,文档刷新后照旧对得上。 */
export interface ViewState {
editing: string[]
collapsed: string[]
}
const bridge = createVsCodeBridge<ExtensionToWebview, WebviewToExtension, ViewState>()
export const { postMessage, onMessage } = bridge
export function loadViewState(): ViewState {
return bridge.getState() ?? { editing: [], collapsed: [] }
}
export function saveViewState(state: ViewState): void {
bridge.setState(state)
}
@@ -0,0 +1,38 @@
import React from 'react'
// ponytail: 无此 boundary 时,任意渲染异常都会让整个 React 树卸载成空白面板
// (用户报告"用着用着突然啥都没了")。捕获后显示错误+重载按钮,免得只能整窗重载。
interface BoundaryState { error: Error | null }
export class ErrorBoundary extends React.Component<{ children: React.ReactNode }, BoundaryState> {
state: BoundaryState = { error: null }
static getDerivedStateFromError(error: Error): BoundaryState {
return { error }
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
console.error('[superpowers webview] render crash:', error, info.componentStack)
}
render(): React.ReactNode {
const { error } = this.state
if (!error)
return this.props.children
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-6 text-center text-[var(--vscode-foreground)]">
<div className="text-sm font-semibold text-[var(--vscode-errorForeground)]">面板渲染出错</div>
<pre className="max-h-60 max-w-full overflow-auto whitespace-pre-wrap text-left text-xs text-[var(--vscode-descriptionForeground)]">
{error.message}
{error.stack ? `\n\n${error.stack}` : ''}
</pre>
<button
type="button"
onClick={() => window.location.reload()}
className="rounded bg-[var(--vscode-button-background)] px-3 py-1 text-xs text-[var(--vscode-button-foreground)] hover:bg-[var(--vscode-button-hoverBackground)]"
>
重新加载
</button>
</div>
)
}
}
+3 -36
View File
@@ -1,39 +1,6 @@
/**
* Tiny typed bridge over the `acquireVsCodeApi()` postMessage channel.
*
* The webview gets exactly one chance to call `acquireVsCodeApi()` per
* lifetime, so we cache the handle module-side. When running outside the
* VS Code host (e.g. `vite dev`) we fall back to no-op stubs.
*/
/** 看板 webview 与扩展之间的收发。 */
import type { ExtensionToWebview, WebviewToExtension } from './messages'
import { createVsCodeBridge } from './vscodeBridge'
interface VsCodeApi {
postMessage: (msg: WebviewToExtension) => void
getState: <T = unknown>() => T | undefined
setState: <T = unknown>(state: T) => void
}
declare global {
interface Window {
acquireVsCodeApi?: () => VsCodeApi
}
}
const api: VsCodeApi = window.acquireVsCodeApi?.() ?? {
postMessage: () => {},
getState: () => undefined,
setState: () => {},
}
export function postMessage(msg: WebviewToExtension): void {
api.postMessage(msg)
}
export function onMessage(handler: (msg: ExtensionToWebview) => void): () => void {
const listener = (ev: MessageEvent): void => {
handler(ev.data as ExtensionToWebview)
}
window.addEventListener('message', listener)
return () => window.removeEventListener('message', listener)
}
export const { postMessage, onMessage } = createVsCodeBridge<ExtensionToWebview, WebviewToExtension>()
+45
View File
@@ -0,0 +1,45 @@
/**
* 对 `acquireVsCodeApi()` 的一层带类型包装:看板和代码阅读各自用自己的消息类型建一个。
*
* 每个 webview 一生只能调用一次 `acquireVsCodeApi()`,所以每个页面只建一个桥。
* 不在 VS Code 里跑(例如 `vite dev`)时退回空实现。
*/
interface VsCodeApi {
postMessage: (msg: unknown) => void
getState: () => unknown
setState: (state: unknown) => void
}
declare global {
interface Window {
acquireVsCodeApi?: () => VsCodeApi
}
}
export interface VsCodeBridge<In, Out, State> {
postMessage: (msg: Out) => void
onMessage: (handler: (msg: In) => void) => () => void
getState: () => State | undefined
setState: (state: State) => void
}
export function createVsCodeBridge<In, Out, State = never>(): VsCodeBridge<In, Out, State> {
const api: VsCodeApi = window.acquireVsCodeApi?.() ?? {
postMessage: () => {},
getState: () => undefined,
setState: () => {},
}
return {
postMessage: msg => api.postMessage(msg),
onMessage: (handler) => {
const listener = (ev: MessageEvent): void => {
handler(ev.data as In)
}
window.addEventListener('message', listener)
return () => window.removeEventListener('message', listener)
},
getState: () => api.getState() as State | undefined,
setState: state => api.setState(state),
}
}
+1 -37
View File
@@ -1,45 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { App } from './App'
import { ErrorBoundary } from './components/ErrorBoundary'
import './index.css'
// ponytail: 无此 boundary 时,任意渲染异常都会让整个 React 树卸载成空白面板
// (用户报告"用着用着突然啥都没了")。捕获后显示错误+重载按钮,免得只能整窗重载。
interface BoundaryState { error: Error | null }
class ErrorBoundary extends React.Component<{ children: React.ReactNode }, BoundaryState> {
state: BoundaryState = { error: null }
static getDerivedStateFromError(error: Error): BoundaryState {
return { error }
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
console.error('[superpowers webview] render crash:', error, info.componentStack)
}
render(): React.ReactNode {
const { error } = this.state
if (!error)
return this.props.children
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-6 text-center text-[var(--vscode-foreground)]">
<div className="text-sm font-semibold text-[var(--vscode-errorForeground)]">面板渲染出错</div>
<pre className="max-h-60 max-w-full overflow-auto whitespace-pre-wrap text-left text-xs text-[var(--vscode-descriptionForeground)]">
{error.message}
{error.stack ? `\n\n${error.stack}` : ''}
</pre>
<button
type="button"
onClick={() => window.location.reload()}
className="rounded bg-[var(--vscode-button-background)] px-3 py-1 text-xs text-[var(--vscode-button-foreground)] hover:bg-[var(--vscode-button-hoverBackground)]"
>
重新加载
</button>
</div>
)
}
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ErrorBoundary>
+10 -1
View File
@@ -9,10 +9,19 @@ export default defineConfig({
build: {
outDir: '../dist/webview-ui',
emptyOutDir: true,
// webview 的 CSP 只放行带 nonce 的 <script>,Vite 注入的 modulepreload <link> 会被拦下报错;
// 本地资源预加载也没有收益。入口脚本 import 的共享块会继承 nonce,照常加载。
modulePreload: false,
rollupOptions: {
// 两个 webview 页面:看板(index.html)和代码阅读预览(code-reader.html)
input: {
'index': path.resolve(__dirname, 'index.html'),
'code-reader': path.resolve(__dirname, 'code-reader.html'),
},
output: {
entryFileNames: 'assets/[name].js',
chunkFileNames: 'assets/[name].js',
// 入口由加载方按 mtime 加 ?v= 防缓存,入口 import 的共享块(React 等)没有这一步,只能靠文件名带内容哈希
chunkFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name].[ext]',
},
},