From cace658ac1b4408687452c56411f0e531868251a Mon Sep 17 00:00:00 2001 From: cruldra Date: Fri, 3 Jul 2026 16:55:40 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20profile=20=E5=80=BC?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E6=94=B9=E4=B8=BA=E5=A4=9A=E8=A1=8C?= =?UTF-8?q?+=E5=9B=BE=E7=89=87=E7=B2=98=E8=B4=B4=EF=BC=88=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E8=90=BD=20.spx/profile-assets=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 双击 profile 值单元格改为打开富编辑弹窗:多行 textarea + Ctrl+V 粘贴截图, ⌘/Ctrl+Enter 或点遮罩保存、Esc 取消。值仍是单个字符串,图片存磁盘 (内容寻址 sha1 命名,去重),值里只留 markdown 引用,不撑大 profiles.json。 折叠单元格显示首行 + 含图时加 🖼 徽标。key/列头仍走原内联输入。 - 抽公用 lib/imagePaste:同步探测(preventDefault) + 异步读取,新建工单改用它、行为不变 - 新增扩展侧 profileAssets handler:存图(image/* 校验)/读图(挡路径穿越) - 新增 useProfileAssets(requestId 关联并发) 与 ProfileCellEditor 弹窗 - 消息两侧同步 profile-asset/save|load|saved|loaded --- vscode/src/panel/KanbanPanel.ts | 9 + vscode/src/panel/handlers/profileAssets.ts | 111 +++++++++ vscode/src/panel/messages.ts | 6 + .../src/components/NewIssueModal.tsx | 38 +-- .../src/components/ProfileCellEditor.tsx | 226 ++++++++++++++++++ .../webview-ui/src/components/ProfileGrid.tsx | 105 ++++---- .../webview-ui/src/hooks/useProfileAssets.ts | 68 ++++++ vscode/webview-ui/src/lib/imagePaste.ts | 62 +++++ vscode/webview-ui/src/lib/messages.ts | 6 + 9 files changed, 549 insertions(+), 82 deletions(-) create mode 100644 vscode/src/panel/handlers/profileAssets.ts create mode 100644 vscode/webview-ui/src/components/ProfileCellEditor.tsx create mode 100644 vscode/webview-ui/src/hooks/useProfileAssets.ts create mode 100644 vscode/webview-ui/src/lib/imagePaste.ts diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index 5e025c4..4ec1a99 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -27,6 +27,7 @@ import { loadYouTrackIssues } from '../youtrack/issueLoader' import * as issues from './handlers/issues' import * as managedSessions from './handlers/managedSessions' import * as prFiles from './handlers/prFiles' +import * as profileAssets from './handlers/profileAssets' import * as sessions from './handlers/sessions' import * as settings from './handlers/settings' import * as terminals from './handlers/terminals' @@ -378,6 +379,14 @@ export class KanbanWebviewPanel { void settings.handleProfilesList(this) return } + if (msg.type === 'profile-asset/save') { + void profileAssets.handleSaveProfileAsset(this, msg) + return + } + if (msg.type === 'profile-asset/load') { + void profileAssets.handleLoadProfileAsset(this, msg) + return + } if (msg.type === 'toast/open-url') { void env.openExternal(Uri.parse(msg.url)) return diff --git a/vscode/src/panel/handlers/profileAssets.ts b/vscode/src/panel/handlers/profileAssets.ts new file mode 100644 index 0000000..2acba44 --- /dev/null +++ b/vscode/src/panel/handlers/profileAssets.ts @@ -0,0 +1,111 @@ +/** + * profile 值编辑器粘贴图片的磁盘存取。 + * + * 图片落到 `/.spx/profile-assets/.`,profile 值里只留 + * markdown 引用 `![](.spx/profile-assets/xxx.png)`——把 base64 塞进 profiles.json + * 会把配置撑成几百 KB 且不可读,故只存引用、内容单独落盘。 + * + * 内容寻址(sha1 前 16 位命名)让同一张图重复粘贴自动去重、复用同一文件。 + * 移除引用只改 profile 值字符串,磁盘孤儿文件本期不做回收(低频、无害)。 + */ + +import type { KanbanWebviewPanel } from '../KanbanPanel' +import { createHash } from 'node:crypto' +import { promises as fsp } from 'node:fs' +import * as path from 'node:path' +import { workspace } from 'vscode' + +/** 相对工作区根的资源目录;也是路径穿越校验的白名单根。 */ +const ASSET_DIR = '.spx/profile-assets' + +/** mediaType → 落盘扩展名;未知一律按 png(前面已保证是 image/*)。 */ +function extForMedia(mediaType: string): string { + switch (mediaType) { + case 'image/jpeg': return 'jpg' + case 'image/gif': return 'gif' + case 'image/webp': return 'webp' + case 'image/svg+xml': return 'svg' + case 'image/png': default: return 'png' + } +} + +/** 扩展名 → mediaType,用于读盘时拼 data URL;未知按 png。 */ +function mediaForExt(ext: string): string { + switch (ext.toLowerCase()) { + case 'jpg': case 'jpeg': return 'image/jpeg' + case 'gif': return 'image/gif' + case 'webp': return 'image/webp' + case 'svg': return 'image/svg+xml' + case 'png': default: return 'image/png' + } +} + +/** + * 把 base64 图片写盘并回传其工作区相对路径。非图片 mediaType 直接拒绝,避免被 + * 当成任意文件写入通道。写盘失败兜底回 error,让编辑器提示而非静默丢图。 + */ +export async function handleSaveProfileAsset( + panel: KanbanWebviewPanel, + args: { requestId: string, base64: string, mediaType: string }, +): Promise { + try { + const root = workspace.workspaceFolders?.[0]?.uri.fsPath + if (!root) { + panel.postMessage({ type: 'profile-asset/saved', requestId: args.requestId, error: '未打开工作区' }) + return + } + if (!args.mediaType.startsWith('image/')) { + panel.postMessage({ type: 'profile-asset/saved', requestId: args.requestId, error: '仅支持图片' }) + return + } + const hash = createHash('sha1').update(args.base64).digest('hex').slice(0, 16) + const rel = `${ASSET_DIR}/${hash}.${extForMedia(args.mediaType)}` + const abs = path.join(root, rel) + await fsp.mkdir(path.dirname(abs), { recursive: true }) + await fsp.writeFile(abs, Buffer.from(args.base64, 'base64')) + panel.postMessage({ type: 'profile-asset/saved', requestId: args.requestId, assetPath: rel }) + } + catch (err) { + panel.postMessage({ + type: 'profile-asset/saved', + requestId: args.requestId, + error: err instanceof Error ? err.message : String(err), + }) + } +} + +/** + * 读回资源文件并拼成 data URL 供编辑器渲染缩略图。 + * + * 安全:assetPath 规范化后必须仍落在 `/.spx/profile-assets/` 内,挡 `..` + * 路径穿越——这个路径来自 profile 值字符串,用户可编辑,不能信。 + */ +export async function handleLoadProfileAsset( + panel: KanbanWebviewPanel, + args: { requestId: string, assetPath: string }, +): Promise { + try { + const root = workspace.workspaceFolders?.[0]?.uri.fsPath + if (!root) { + panel.postMessage({ type: 'profile-asset/loaded', requestId: args.requestId, error: '未打开工作区' }) + return + } + const baseDir = path.resolve(root, ASSET_DIR) + const abs = path.resolve(root, args.assetPath) + if (abs !== baseDir && !abs.startsWith(baseDir + path.sep)) { + panel.postMessage({ type: 'profile-asset/loaded', requestId: args.requestId, error: '非法资源路径' }) + return + } + const buf = await fsp.readFile(abs) + const ext = path.extname(abs).slice(1) + const dataUrl = `data:${mediaForExt(ext)};base64,${buf.toString('base64')}` + panel.postMessage({ type: 'profile-asset/loaded', requestId: args.requestId, dataUrl }) + } + catch (err) { + panel.postMessage({ + type: 'profile-asset/loaded', + requestId: args.requestId, + error: err instanceof Error ? err.message : String(err), + }) + } +} diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index a914448..3a59cc5 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -103,6 +103,9 @@ export type ExtensionToWebview | { type: 'pr-files/show', issueNumber: number, files: PrFile[], confirmed: string[], summaries: Record, error?: string } | { type: 'pr-file-diff/show', issueNumber: number, path: string, oldContent: string, newContent: string, oldLang?: string, newLang?: string, error?: string } | { type: 'pr-file-summary/show', issueNumber: number, path: string, summary?: string, error?: string } + // profile 值编辑器粘贴的图片:存盘/读盘的回执,requestId 关联并发请求。 + | { type: 'profile-asset/saved', requestId: string, assetPath?: string, error?: string } + | { type: 'profile-asset/loaded', requestId: string, dataUrl?: string, error?: string } export type WebviewToExtension = | { type: 'issues/refresh' } @@ -177,3 +180,6 @@ export type WebviewToExtension | { type: 'pr-file-diff/get', issueNumber: number, path: string, previousPath?: string } | { type: 'pr-file-summary/generate', issueNumber: number, path: string, previousPath?: string } | { type: 'pr-review/set', issueNumber: number, confirmed: Record } + // profile 值编辑器:图片存盘(base64→磁盘)与读盘(磁盘→dataUrl)请求。 + | { type: 'profile-asset/save', requestId: string, base64: string, mediaType: string } + | { type: 'profile-asset/load', requestId: string, assetPath: string } diff --git a/vscode/webview-ui/src/components/NewIssueModal.tsx b/vscode/webview-ui/src/components/NewIssueModal.tsx index fceda1a..34a4379 100644 --- a/vscode/webview-ui/src/components/NewIssueModal.tsx +++ b/vscode/webview-ui/src/components/NewIssueModal.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { X } from 'lucide-react' +import { getPastedImageFiles, readClipboardImages } from '../lib/imagePaste' export interface PastedImage { mediaType: string @@ -37,28 +38,6 @@ interface Props { defaultProfileName?: string } -function readClipboardImage(file: File): Promise { - return new Promise((resolve) => { - const reader = new FileReader() - reader.onload = () => { - const result = reader.result - if (typeof result !== 'string') { - resolve(null) - return - } - // result shape: "data:image/png;base64,iVBORw0K..." - const commaIdx = result.indexOf(',') - const header = commaIdx > 0 ? result.slice(0, commaIdx) : '' - const base64 = commaIdx > 0 ? result.slice(commaIdx + 1) : '' - const mediaMatch = header.match(/^data:([^;]+);base64$/) - const mediaType = mediaMatch ? mediaMatch[1] : file.type || 'image/png' - resolve({ mediaType, base64, previewDataUrl: result }) - } - reader.onerror = () => resolve(null) - reader.readAsDataURL(file) - }) -} - export function NewIssueModal({ open, onCancel, onSubmit, profiles, defaultProfileName }: Props) { const [value, setValue] = useState('') const [images, setImages] = useState([]) @@ -100,22 +79,11 @@ export function NewIssueModal({ open, onCancel, onSubmit, profiles, defaultProfi }, [open, onCancel, profiles, defaultProfileName]) const handlePaste = useCallback(async (e: React.ClipboardEvent) => { - const items = e.clipboardData?.items - if (!items) - return - const files: File[] = [] - for (const item of items) { - if (item.kind === 'file' && item.type.startsWith('image/')) { - const f = item.getAsFile() - if (f) - files.push(f) - } - } + const files = getPastedImageFiles(e) if (files.length > 0) { // Intercept so the file binary doesn't end up pasted as garbled text. e.preventDefault() - const parsed = await Promise.all(files.map(readClipboardImage)) - const ok = parsed.filter((p): p is PastedImage => p !== null) + const ok = await readClipboardImages(files) if (ok.length === 0) return // Assign sequential numbers; mirror pastedTexts and insert `[Image #N]` diff --git a/vscode/webview-ui/src/components/ProfileCellEditor.tsx b/vscode/webview-ui/src/components/ProfileCellEditor.tsx new file mode 100644 index 0000000..498bf29 --- /dev/null +++ b/vscode/webview-ui/src/components/ProfileCellEditor.tsx @@ -0,0 +1,226 @@ +/** + * profile 值单元格的富编辑弹窗:多行 textarea + 图片粘贴,对齐「新建工单」体验。 + * + * 与新建工单的关键区别——这里的值要**持久化**,故粘贴的图片走磁盘:saveAsset 落盘 + * 回相对路径,值里只插 markdown 引用 `![](.spx/profile-assets/xxx.png)`;打开旧值时 + * 解析出这些引用、loadAsset 拉回 data URL 渲染缩略图。值始终是单个字符串,不改 + * profiles.json 结构。 + * + * 提交语义:⌘/Ctrl+Enter 或点「保存」提交;Esc 或「取消」丢弃;点遮罩(失焦)按提交 + * 处理(沿用旧内联输入「失焦即存」的习惯)。Enter 现在是换行。 + */ + +import type { PastedImage } from '../lib/imagePaste' +import { X } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { getPastedImageFiles, readClipboardImages } from '../lib/imagePaste' +import { useProfileAssets } from '../hooks/useProfileAssets' + +interface Props { + keyLabel: string + profileLabel: string + initialValue: string + onSave: (value: string) => void + onCancel: () => void +} + +interface ImageEntry { + id: string + /** 值里对应的引用整段,如 `![](.spx/profile-assets/ab12.png)`,移除时据此从值里删。 */ + token: string + /** 缩略图 data URL(粘贴时来自剪贴板、打开旧值时来自 loadAsset)。 */ + previewUrl: string +} + +/** 匹配值里的资源引用,捕获组 1 是相对路径。 */ +const ASSET_REF_RE = /!\[[^\]]*\]\((\.spx\/profile-assets\/[^)\s]+)\)/g + +function newId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + +export function ProfileCellEditor({ keyLabel, profileLabel, initialValue, onSave, onCancel }: Props) { + const [value, setValue] = useState(initialValue) + const [images, setImages] = useState([]) + const [busy, setBusy] = useState(false) + const textareaRef = useRef(null) + const { saveAsset, loadAsset } = useProfileAssets() + + // 挂载即聚焦 + Esc 取消(与新建工单一致的整窗 Esc 监听)。 + useEffect(() => { + const t = setTimeout(() => textareaRef.current?.focus(), 0) + function onKey(e: KeyboardEvent): void { + if (e.key === 'Escape') + onCancel() + } + document.addEventListener('keydown', onKey) + return () => { + clearTimeout(t) + document.removeEventListener('keydown', onKey) + } + }, [onCancel]) + + // 打开旧值:解析其中的资源引用,逐个拉回 data URL 建缩略图。读失败的静默跳过 + // (文件可能已被手动删),不挡编辑。仅在初值上跑一次。 + useEffect(() => { + let cancelled = false + const refs = [...initialValue.matchAll(ASSET_REF_RE)].map(m => ({ token: m[0], path: m[1] })) + if (refs.length === 0) + return + void Promise.all(refs.map(async (r) => { + try { + const previewUrl = await loadAsset(r.path) + return { id: newId(), token: r.token, previewUrl } satisfies ImageEntry + } + catch { + return null + } + })).then((entries) => { + if (!cancelled) + setImages(entries.filter((e): e is ImageEntry => e !== null)) + }) + return () => { + cancelled = true + } + }, [initialValue, loadAsset]) + + /** 在当前光标处插入一段文本并把光标移到其后。 */ + function insertAtCaret(insert: string): void { + const ta = textareaRef.current + const start = ta?.selectionStart ?? value.length + const end = ta?.selectionEnd ?? value.length + const next = value.slice(0, start) + insert + value.slice(end) + setValue(next) + requestAnimationFrame(() => { + if (textareaRef.current) { + const pos = start + insert.length + textareaRef.current.selectionStart = textareaRef.current.selectionEnd = pos + textareaRef.current.focus() + } + }) + } + + async function handlePaste(e: React.ClipboardEvent): Promise { + const files = getPastedImageFiles(e) + if (files.length === 0) + return + // 拦截默认粘贴(二进制别当乱码插进来),改走存盘。 + e.preventDefault() + const imgs = await readClipboardImages(files) + if (imgs.length === 0) + return + setBusy(true) + try { + const saved = await Promise.all(imgs.map(async (img: PastedImage) => { + try { + const assetPath = await saveAsset(img.base64, img.mediaType) + return { assetPath, previewUrl: img.previewDataUrl } + } + catch { + return null + } + })) + const ok = saved.filter((s): s is { assetPath: string, previewUrl: string } => s !== null) + if (ok.length === 0) + return + const entries: ImageEntry[] = ok.map(s => ({ + id: newId(), + token: `![](${s.assetPath})`, + previewUrl: s.previewUrl, + })) + setImages(prev => [...prev, ...entries]) + insertAtCaret(entries.map(en => en.token).join('\n')) + } + finally { + setBusy(false) + } + } + + /** 移除缩略图:同时从值里删掉它的引用整段。磁盘文件不回收(低频、无害)。 */ + function removeImage(id: string): void { + const target = images.find(i => i.id === id) + setImages(prev => prev.filter(i => i.id !== id)) + if (target) + setValue(prev => prev.split(target.token).join('')) + } + + function commit(): void { + onSave(value) + } + + function handleKeyDown(e: React.KeyboardEvent): void { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault() + commit() + } + } + + return ( +
+
e.stopPropagation()} + > +

+ 编辑值 · + {' '} + {`${keyLabel} / ${profileLabel}`} +

+