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 issues from './handlers/issues'
import * as managedSessions from './handlers/managedSessions' import * as managedSessions from './handlers/managedSessions'
import * as prFiles from './handlers/prFiles' import * as prFiles from './handlers/prFiles'
import * as profileAssets from './handlers/profileAssets'
import * as sessions from './handlers/sessions' import * as sessions from './handlers/sessions'
import * as settings from './handlers/settings' import * as settings from './handlers/settings'
import * as terminals from './handlers/terminals' import * as terminals from './handlers/terminals'
@@ -378,6 +379,14 @@ export class KanbanWebviewPanel {
void settings.handleProfilesList(this) void settings.handleProfilesList(this)
return 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') { if (msg.type === 'toast/open-url') {
void env.openExternal(Uri.parse(msg.url)) void env.openExternal(Uri.parse(msg.url))
return 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-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-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 } | { 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 export type WebviewToExtension
= | { type: 'issues/refresh' } = | { type: 'issues/refresh' }
@@ -177,3 +180,6 @@ export type WebviewToExtension
| { type: 'pr-file-diff/get', issueNumber: number, path: string, previousPath?: string } | { type: 'pr-file-diff/get', issueNumber: number, path: string, previousPath?: string }
| { type: 'pr-file-summary/generate', 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> } | { 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 }
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { getPastedImageFiles, readClipboardImages } from '../lib/imagePaste'
export interface PastedImage { export interface PastedImage {
mediaType: string mediaType: string
@@ -37,28 +38,6 @@ interface Props {
defaultProfileName?: string defaultProfileName?: string
} }
function readClipboardImage(file: File): Promise<PastedImage | null> {
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) { export function NewIssueModal({ open, onCancel, onSubmit, profiles, defaultProfileName }: Props) {
const [value, setValue] = useState('') const [value, setValue] = useState('')
const [images, setImages] = useState<PastedImageWithId[]>([]) const [images, setImages] = useState<PastedImageWithId[]>([])
@@ -100,22 +79,11 @@ export function NewIssueModal({ open, onCancel, onSubmit, profiles, defaultProfi
}, [open, onCancel, profiles, defaultProfileName]) }, [open, onCancel, profiles, defaultProfileName])
const handlePaste = useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement>) => { const handlePaste = useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = e.clipboardData?.items const files = getPastedImageFiles(e)
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)
}
}
if (files.length > 0) { if (files.length > 0) {
// Intercept so the file binary doesn't end up pasted as garbled text. // Intercept so the file binary doesn't end up pasted as garbled text.
e.preventDefault() e.preventDefault()
const parsed = await Promise.all(files.map(readClipboardImage)) const ok = await readClipboardImages(files)
const ok = parsed.filter((p): p is PastedImage => p !== null)
if (ok.length === 0) if (ok.length === 0)
return return
// Assign sequential numbers; mirror pastedTexts and insert `[Image #N]` // Assign sequential numbers; mirror pastedTexts and insert `[Image #N]`
@@ -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<ImageEntry[]>([])
const [busy, setBusy] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>): Promise<void> {
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<HTMLTextAreaElement>): void {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault()
commit()
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onMouseDown={commit}
>
<div
role="dialog"
aria-modal="true"
className="w-full max-w-xl rounded-md border border-[var(--vscode-panel-border)] bg-[var(--vscode-editor-background)] p-4 shadow-xl"
onMouseDown={e => e.stopPropagation()}
>
<h2 className="mb-3 truncate text-base font-medium">
·
{' '}
<span className="text-[var(--vscode-descriptionForeground)]">{`${keyLabel} / ${profileLabel}`}</span>
</h2>
<textarea
ref={textareaRef}
value={value}
onChange={e => setValue(e.target.value)}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
placeholder="多行文本;可 Ctrl+V 粘贴截图(⌘/Ctrl+Enter 保存,Esc 取消)"
className="mb-2 h-72 w-full resize-none rounded border border-[var(--vscode-input-border,transparent)] bg-[var(--vscode-input-background)] p-2 font-mono text-xs text-[var(--vscode-input-foreground)] outline-none focus:border-[var(--vscode-focusBorder)]"
/>
{images.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
{images.map(img => (
<div
key={img.id}
className="group relative h-16 w-16 overflow-hidden rounded border border-[var(--vscode-panel-border)]"
>
<img src={img.previewUrl} alt="pasted" className="h-full w-full object-cover" />
<button
type="button"
onClick={() => removeImage(img.id)}
aria-label="移除"
className="absolute right-0.5 top-0.5 grid size-4 place-items-center rounded-full bg-black/60 text-white opacity-0 transition-opacity group-hover:opacity-100"
>
<X className="size-2.5" />
</button>
</div>
))}
</div>
)}
<div className="flex items-center justify-end gap-2">
{busy && <span className="mr-auto text-xs text-[var(--vscode-descriptionForeground)]"></span>}
<button
type="button"
onClick={onCancel}
className="rounded border border-[var(--vscode-button-border,transparent)] bg-[var(--vscode-button-secondaryBackground)] px-3 py-1.5 text-xs text-[var(--vscode-button-secondaryForeground)] hover:bg-[var(--vscode-button-secondaryHoverBackground)]"
>
</button>
<button
type="button"
onClick={commit}
disabled={busy}
className="rounded bg-[var(--vscode-button-background)] px-3 py-1.5 text-xs text-[var(--vscode-button-foreground)] hover:bg-[var(--vscode-button-hoverBackground)] disabled:cursor-not-allowed disabled:opacity-50"
>
</button>
</div>
</div>
</div>
)
}
@@ -10,8 +10,9 @@
*/ */
import type { ProfileRow, ProfilesData } from '../lib/messages' import type { ProfileRow, ProfilesData } from '../lib/messages'
import { ExternalLink, Folder, Plus } from 'lucide-react' import { ExternalLink, Folder, Image as ImageIcon, Plus } from 'lucide-react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { ProfileCellEditor } from './ProfileCellEditor'
interface ProfileGridProps { interface ProfileGridProps {
data: ProfilesData data: ProfilesData
@@ -33,6 +34,11 @@ type EditTarget
const URL_RE = /^(?:https?:\/\/|git@)/i const URL_RE = /^(?:https?:\/\/|git@)/i
const WIN_PATH_RE = /^[A-Z]:[\\/]/i const WIN_PATH_RE = /^[A-Z]:[\\/]/i
/** 值里是否含粘贴图片的引用,用来给折叠单元格加 🖼 徽标。 */
function hasImageRef(value: string): boolean {
return /!\[[^\]]*\]\(\.spx\/profile-assets\//.test(value)
}
function classifyValue(value: string): 'url' | 'path' | null { function classifyValue(value: string): 'url' | 'path' | null {
const v = value.trim() const v = value.trim()
if (!v) if (!v)
@@ -51,6 +57,8 @@ export function ProfileGrid({ data, onSave, onOpen }: ProfileGridProps) {
const [editing, setEditing] = useState<EditTarget | null>(null) const [editing, setEditing] = useState<EditTarget | null>(null)
const [draft, setDraft] = useState<string>('') const [draft, setDraft] = useState<string>('')
const [menu, setMenu] = useState<MenuState | null>(null) const [menu, setMenu] = useState<MenuState | null>(null)
// 值单元格改用富编辑弹窗(多行 + 图片),key/列头仍走内联 input。
const [cellEdit, setCellEdit] = useState<{ row: number, profile: string } | null>(null)
const inputRef = useRef<HTMLInputElement | null>(null) const inputRef = useRef<HTMLInputElement | null>(null)
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -322,6 +330,7 @@ export function ProfileGrid({ data, onSave, onOpen }: ProfileGridProps) {
setDraft={setDraft} setDraft={setDraft}
openKeyMenu={openKeyMenu} openKeyMenu={openKeyMenu}
onOpen={onOpen} onOpen={onOpen}
onEditCell={profile => setCellEdit({ row: rIdx, profile })}
/> />
))} ))}
@@ -366,6 +375,20 @@ export function ProfileGrid({ data, onSave, onOpen }: ProfileGridProps) {
))} ))}
</div> </div>
)} )}
{/* 值单元格富编辑弹窗:双击值格打开,多行 + 图片粘贴 */}
{cellEdit && (
<ProfileCellEditor
keyLabel={rows[cellEdit.row]?.key ?? ''}
profileLabel={cellEdit.profile}
initialValue={rows[cellEdit.row]?.values[cellEdit.profile] ?? ''}
onSave={(val) => {
commitEdit({ kind: 'cell', row: cellEdit.row, profile: cellEdit.profile }, val)
setCellEdit(null)
}}
onCancel={() => setCellEdit(null)}
/>
)}
</div> </div>
) )
} }
@@ -385,6 +408,7 @@ interface RowFragmentProps {
setDraft: (v: string) => void setDraft: (v: string) => void
openKeyMenu: (e: React.MouseEvent, row: number) => void openKeyMenu: (e: React.MouseEvent, row: number) => void
onOpen: (value: string) => void onOpen: (value: string) => void
onEditCell: (profile: string) => void
} }
function RowFragment({ function RowFragment({
@@ -402,6 +426,7 @@ function RowFragment({
setDraft, setDraft,
openKeyMenu, openKeyMenu,
onOpen, onOpen,
onEditCell,
}: RowFragmentProps) { }: RowFragmentProps) {
return ( return (
<> <>
@@ -438,57 +463,43 @@ function RowFragment({
</div> </div>
{profiles.map((profile, col) => { {profiles.map((profile, col) => {
const value = row.values[profile] ?? '' const value = row.values[profile] ?? ''
const isEditing = editing && editing.kind === 'cell' && editing.row === rIdx && editing.profile === profile // 折叠态只显示首行;多行/含图的值不再当 URL/path 处理(不显示打开图标)。
const kind = classifyValue(value) const singleLine = !value.includes('\n')
const firstLine = singleLine ? value : value.split('\n', 1)[0]
const kind = singleLine ? classifyValue(value) : null
const withImage = hasImageRef(value)
return ( return (
<div <div
key={`r${rIdx}-c${col}`} key={`r${rIdx}-c${col}`}
className={`${cellBase} ${editableHover} group px-2`} className={`${cellBase} ${editableHover} group px-2`}
onDoubleClick={() => beginEdit({ kind: 'cell', row: rIdx, profile })} onDoubleClick={() => onEditCell(profile)}
title="双击编辑" title="双击编辑(多行 + 图片)"
> >
{isEditing <div className="flex w-full min-w-0 items-center gap-1.5">
? ( <span className="min-w-0 flex-1 truncate">
<input {firstLine || <span className="opacity-40">()</span>}
ref={inputRef} </span>
type="text" {withImage && (
value={draft} <ImageIcon
onChange={e => setDraft(e.target.value)} className="size-3.5 shrink-0 text-[var(--vscode-descriptionForeground)]"
onBlur={finishEdit} aria-label="含图片"
onKeyDown={(e) => { />
if (e.key === 'Enter') { )}
e.preventDefault() {kind && (
finishEdit() <button
} type="button"
else if (e.key === 'Escape') { onClick={(e) => {
e.preventDefault() e.stopPropagation()
cancelEdit() onOpen(value)
} }}
}} title={kind === 'url' ? '在浏览器打开' : '在系统中打开'}
className="w-full bg-transparent px-0 py-0 text-xs text-[var(--vscode-input-foreground)] outline-none focus:ring-1 focus:ring-inset focus:ring-[var(--vscode-focusBorder)]" aria-label={kind === 'url' ? '在浏览器打开' : '在系统中打开'}
/> className="grid size-5 shrink-0 place-items-center rounded opacity-0 transition-opacity hover:bg-[var(--vscode-toolbar-hoverBackground,var(--vscode-list-hoverBackground))] hover:opacity-100 group-hover:opacity-70"
) >
: ( {kind === 'url' ? <ExternalLink className="size-3.5" /> : <Folder className="size-3.5" />}
<div className="flex w-full min-w-0 items-center gap-1.5"> </button>
<span className="min-w-0 flex-1 truncate"> )}
{value || <span className="opacity-40">()</span>} </div>
</span>
{kind && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onOpen(value)
}}
title={kind === 'url' ? '在浏览器打开' : '在系统中打开'}
aria-label={kind === 'url' ? '在浏览器打开' : '在系统中打开'}
className="grid size-5 shrink-0 place-items-center rounded opacity-0 transition-opacity hover:bg-[var(--vscode-toolbar-hoverBackground,var(--vscode-list-hoverBackground))] hover:opacity-100 group-hover:opacity-70"
>
{kind === 'url' ? <ExternalLink className="size-3.5" /> : <Folder className="size-3.5" />}
</button>
)}
</div>
)}
</div> </div>
) )
})} })}
@@ -0,0 +1,68 @@
/**
* profile 值编辑器的图片存/读桥:把 `profile-asset/save|load` 的一问一答封装成
* Promise。用 requestId 关联并发请求——同时粘多张图时,回执按 id 各归各的。
*/
import { useCallback, useEffect, useRef } from 'react'
import { onMessage, postMessage } from '../lib/vscode'
interface Pending {
resolve: (value: string) => void
reject: (err: Error) => void
}
export interface UseProfileAssetsResult {
/** 存图,resolve 出工作区相对路径(写进 profile 值的引用)。 */
saveAsset: (base64: string, mediaType: string) => Promise<string>
/** 读图,resolve 出 data URL(渲染缩略图)。 */
loadAsset: (assetPath: string) => Promise<string>
}
export function useProfileAssets(): UseProfileAssetsResult {
const pending = useRef<Map<string, Pending>>(new Map())
useEffect(() => {
return onMessage((msg) => {
if (msg.type === 'profile-asset/saved') {
const p = pending.current.get(msg.requestId)
if (!p)
return
pending.current.delete(msg.requestId)
if (msg.error || !msg.assetPath)
p.reject(new Error(msg.error ?? '保存失败'))
else
p.resolve(msg.assetPath)
}
else if (msg.type === 'profile-asset/loaded') {
const p = pending.current.get(msg.requestId)
if (!p)
return
pending.current.delete(msg.requestId)
if (msg.error || !msg.dataUrl)
p.reject(new Error(msg.error ?? '读取失败'))
else
p.resolve(msg.dataUrl)
}
})
}, [])
const newId = (): string => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const saveAsset = useCallback((base64: string, mediaType: string): Promise<string> => {
const requestId = newId()
return new Promise<string>((resolve, reject) => {
pending.current.set(requestId, { resolve, reject })
postMessage({ type: 'profile-asset/save', requestId, base64, mediaType })
})
}, [])
const loadAsset = useCallback((assetPath: string): Promise<string> => {
const requestId = newId()
return new Promise<string>((resolve, reject) => {
pending.current.set(requestId, { resolve, reject })
postMessage({ type: 'profile-asset/load', requestId, assetPath })
})
}, [])
return { saveAsset, loadAsset }
}
+62
View File
@@ -0,0 +1,62 @@
/**
* 剪贴板图片提取的公用件,供「新建工单」与 profile 值编辑器共享。
*
* 刻意拆成「同步探测」+「异步读取」两步:粘贴的默认行为(把二进制当乱码文本插入)
* 必须在事件处理里同步 `preventDefault` 才拦得住,而读文件是异步的。所以调用方先用
* `getPastedImageFiles` 同步拿到图片文件、立刻 preventDefault,再 await `readClipboardImages`。
*/
export interface PastedImage {
/** e.g. "image/png" */
mediaType: string
/** 裸 base64(不含 `data:...;base64,` 前缀) */
base64: string
/** `data:...;base64,...` 形式,供 `<img src>` 直接预览 */
previewDataUrl: string
}
/** 同步从粘贴事件里挑出所有 image/* 文件;无图返回空数组(调用方据此决定是否 preventDefault)。 */
export function getPastedImageFiles(e: React.ClipboardEvent): File[] {
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)
}
}
return files
}
/** 把单个图片文件读成 base64 + data URL;读失败回 null。 */
export function readClipboardImage(file: File): Promise<PastedImage | null> {
return new Promise((resolve) => {
const reader = new FileReader()
reader.onload = () => {
const result = reader.result
if (typeof result !== 'string') {
resolve(null)
return
}
// result 形如 "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 readClipboardImages(files: File[]): Promise<PastedImage[]> {
return Promise.all(files.map(readClipboardImage)).then(
arr => arr.filter((p): p is PastedImage => p !== null),
)
}
+6
View File
@@ -118,6 +118,9 @@ export type ExtensionToWebview
| { type: 'pr-files/show', issueNumber: number, files: PrFile[], confirmed: string[], summaries: Record<string, string>, error?: string } | { 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-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 } | { 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 export type WebviewToExtension
= | { type: 'issues/refresh' } = | { type: 'issues/refresh' }
@@ -192,3 +195,6 @@ export type WebviewToExtension
| { type: 'pr-file-diff/get', issueNumber: number, path: string, previousPath?: string } | { type: 'pr-file-diff/get', issueNumber: number, path: string, previousPath?: string }
| { type: 'pr-file-summary/generate', 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> } | { 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 }