✨ 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:
@@ -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
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* profile 值编辑器粘贴图片的磁盘存取。
|
||||
*
|
||||
* 图片落到 `<workspace>/.spx/profile-assets/<sha1前16>.<ext>`,profile 值里只留
|
||||
* markdown 引用 ``——把 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user