feat(vscode): profile 值编辑器改为多行+图片粘贴(图片落 .spx/profile-assets)

双击 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
This commit is contained in:
2026-07-03 16:55:40 +08:00
parent 35085f72f7
commit cace658ac1
9 changed files with 549 additions and 82 deletions
+9
View File
@@ -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
+111
View File
@@ -0,0 +1,111 @@
/**
* profile 值编辑器粘贴图片的磁盘存取。
*
* 图片落到 `<workspace>/.spx/profile-assets/<sha1前16>.<ext>`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<void> {
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 规范化后必须仍落在 `<root>/.spx/profile-assets/` 内,挡 `..`
* 路径穿越——这个路径来自 profile 值字符串,用户可编辑,不能信。
*/
export async function handleLoadProfileAsset(
panel: KanbanWebviewPanel,
args: { requestId: string, assetPath: string },
): Promise<void> {
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),
})
}
}
+6
View File
@@ -103,6 +103,9 @@ export type ExtensionToWebview
| { type: 'pr-files/show', issueNumber: number, files: PrFile[], confirmed: string[], summaries: Record<string, string>, 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<string, string> }
// profile 值编辑器:图片存盘(base64→磁盘)与读盘(磁盘→dataUrl)请求。
| { type: 'profile-asset/save', requestId: string, base64: string, mediaType: string }
| { type: 'profile-asset/load', requestId: string, assetPath: string }