✨ 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 }
|
||||
|
||||
@@ -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<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) {
|
||||
const [value, setValue] = useState('')
|
||||
const [images, setImages] = useState<PastedImageWithId[]>([])
|
||||
@@ -100,22 +79,11 @@ export function NewIssueModal({ open, onCancel, onSubmit, profiles, defaultProfi
|
||||
}, [open, onCancel, profiles, defaultProfileName])
|
||||
|
||||
const handlePaste = useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
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]`
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* profile 值单元格的富编辑弹窗:多行 textarea + 图片粘贴,对齐「新建工单」体验。
|
||||
*
|
||||
* 与新建工单的关键区别——这里的值要**持久化**,故粘贴的图片走磁盘:saveAsset 落盘
|
||||
* 回相对路径,值里只插 markdown 引用 ``;打开旧值时
|
||||
* 解析出这些引用、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
|
||||
/** 值里对应的引用整段,如 ``,移除时据此从值里删。 */
|
||||
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: ``,
|
||||
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 { 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 { ProfileCellEditor } from './ProfileCellEditor'
|
||||
|
||||
interface ProfileGridProps {
|
||||
data: ProfilesData
|
||||
@@ -33,6 +34,11 @@ type EditTarget
|
||||
const URL_RE = /^(?:https?:\/\/|git@)/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 {
|
||||
const v = value.trim()
|
||||
if (!v)
|
||||
@@ -51,6 +57,8 @@ export function ProfileGrid({ data, onSave, onOpen }: ProfileGridProps) {
|
||||
const [editing, setEditing] = useState<EditTarget | null>(null)
|
||||
const [draft, setDraft] = useState<string>('')
|
||||
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)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -322,6 +330,7 @@ export function ProfileGrid({ data, onSave, onOpen }: ProfileGridProps) {
|
||||
setDraft={setDraft}
|
||||
openKeyMenu={openKeyMenu}
|
||||
onOpen={onOpen}
|
||||
onEditCell={profile => setCellEdit({ row: rIdx, profile })}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -366,6 +375,20 @@ export function ProfileGrid({ data, onSave, onOpen }: ProfileGridProps) {
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -385,6 +408,7 @@ interface RowFragmentProps {
|
||||
setDraft: (v: string) => void
|
||||
openKeyMenu: (e: React.MouseEvent, row: number) => void
|
||||
onOpen: (value: string) => void
|
||||
onEditCell: (profile: string) => void
|
||||
}
|
||||
|
||||
function RowFragment({
|
||||
@@ -402,6 +426,7 @@ function RowFragment({
|
||||
setDraft,
|
||||
openKeyMenu,
|
||||
onOpen,
|
||||
onEditCell,
|
||||
}: RowFragmentProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -438,57 +463,43 @@ function RowFragment({
|
||||
</div>
|
||||
{profiles.map((profile, col) => {
|
||||
const value = row.values[profile] ?? ''
|
||||
const isEditing = editing && editing.kind === 'cell' && editing.row === rIdx && editing.profile === profile
|
||||
const kind = classifyValue(value)
|
||||
// 折叠态只显示首行;多行/含图的值不再当 URL/path 处理(不显示打开图标)。
|
||||
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 (
|
||||
<div
|
||||
key={`r${rIdx}-c${col}`}
|
||||
className={`${cellBase} ${editableHover} group px-2`}
|
||||
onDoubleClick={() => beginEdit({ kind: 'cell', row: rIdx, profile })}
|
||||
title="双击编辑"
|
||||
onDoubleClick={() => onEditCell(profile)}
|
||||
title="双击编辑(多行 + 图片)"
|
||||
>
|
||||
{isEditing
|
||||
? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onBlur={finishEdit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
finishEdit()
|
||||
}
|
||||
else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEdit()
|
||||
}
|
||||
}}
|
||||
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)]"
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className="flex w-full min-w-0 items-center gap-1.5">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{value || <span className="opacity-40">(空)</span>}
|
||||
</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 className="flex w-full min-w-0 items-center gap-1.5">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{firstLine || <span className="opacity-40">(空)</span>}
|
||||
</span>
|
||||
{withImage && (
|
||||
<ImageIcon
|
||||
className="size-3.5 shrink-0 text-[var(--vscode-descriptionForeground)]"
|
||||
aria-label="含图片"
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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-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' }
|
||||
@@ -192,3 +195,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