✨ 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 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
|
||||||
|
|||||||
@@ -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-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 引用 ``;打开旧值时
|
||||||
|
* 解析出这些引用、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 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,41 +463,28 @@ 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
|
|
||||||
? (
|
|
||||||
<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">
|
<div className="flex w-full min-w-0 items-center gap-1.5">
|
||||||
<span className="min-w-0 flex-1 truncate">
|
<span className="min-w-0 flex-1 truncate">
|
||||||
{value || <span className="opacity-40">(空)</span>}
|
{firstLine || <span className="opacity-40">(空)</span>}
|
||||||
</span>
|
</span>
|
||||||
|
{withImage && (
|
||||||
|
<ImageIcon
|
||||||
|
className="size-3.5 shrink-0 text-[var(--vscode-descriptionForeground)]"
|
||||||
|
aria-label="含图片"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{kind && (
|
{kind && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -488,7 +500,6 @@ function RowFragment({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 }
|
||||||
|
}
|
||||||
@@ -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-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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user