✨ feat(vscode): 代码阅读预览界面
Vite 加 code-reader.html 第二入口;代码块树(层级竖线、折叠、种类标签配色)、译文 / 源码按词性着色、块内直接编辑(防抖回写整行、Esc 回预览)、翻译下拉、解析失败提示;看板与代码阅读共用带类型的 acquireVsCodeApi 包装和 ErrorBoundary。
This commit is contained in:
1 parent
cab5ac0ebc
commit
af56985d9a
16 files changed
+775
-74
No files matched your search
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>()
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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]',
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user