双击 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
510 lines
17 KiB
TypeScript
510 lines
17 KiB
TypeScript
/**
|
||
* 工作区 profile 表(KV grid)。
|
||
*
|
||
* 行 = key,列 = profile(dev / prod / 自定义…)。
|
||
* - 单元格双击进入 input 编辑(key 和每个 value 都支持),失焦或回车提交,Esc 取消
|
||
* - 列头双击改名;右键弹自定义菜单"删除 profile"(至少保留 1 个)
|
||
* - 右键 key 单元格弹"删除 key"
|
||
* - 右边显示 `+` 按钮追加新 profile,末尾一行有 `+ 添加 key` 按钮
|
||
* - 单元格值识别 URL / path-like,显示对应图标,点击 onOpen(value) 由主进程做实际打开
|
||
*/
|
||
|
||
import type { ProfileRow, ProfilesData } from '../lib/messages'
|
||
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
|
||
onSave: (next: ProfilesData) => void
|
||
onOpen: (value: string) => void
|
||
}
|
||
|
||
interface MenuState {
|
||
x: number
|
||
y: number
|
||
items: Array<{ label: string, onClick: () => void, danger?: boolean }>
|
||
}
|
||
|
||
type EditTarget
|
||
= | { kind: 'header', col: number }
|
||
| { kind: 'key', row: number }
|
||
| { kind: 'cell', row: number, profile: string }
|
||
|
||
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)
|
||
return null
|
||
if (URL_RE.test(v))
|
||
return 'url'
|
||
if (v.startsWith('/') || v.startsWith('~') || WIN_PATH_RE.test(v))
|
||
return 'path'
|
||
// 含斜杠且不像 URL → 视作相对路径
|
||
if (v.includes('/'))
|
||
return 'path'
|
||
return null
|
||
}
|
||
|
||
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(() => {
|
||
if (!editing)
|
||
return
|
||
const el = inputRef.current
|
||
if (!el)
|
||
return
|
||
el.focus()
|
||
el.select()
|
||
}, [editing])
|
||
|
||
// 外部点击关闭右键菜单
|
||
useEffect(() => {
|
||
if (!menu)
|
||
return
|
||
function onDocClick(): void {
|
||
setMenu(null)
|
||
}
|
||
function onEsc(e: KeyboardEvent): void {
|
||
if (e.key === 'Escape')
|
||
setMenu(null)
|
||
}
|
||
window.addEventListener('mousedown', onDocClick)
|
||
window.addEventListener('keydown', onEsc)
|
||
return () => {
|
||
window.removeEventListener('mousedown', onDocClick)
|
||
window.removeEventListener('keydown', onEsc)
|
||
}
|
||
}, [menu])
|
||
|
||
const profiles = data.profiles
|
||
const rows = data.rows
|
||
|
||
const commitEdit = useCallback((target: EditTarget, newValue: string): void => {
|
||
if (target.kind === 'header') {
|
||
const trimmed = newValue.trim()
|
||
if (!trimmed)
|
||
return
|
||
const old = profiles[target.col]
|
||
if (trimmed === old)
|
||
return
|
||
// 同名去重:如果新名已存在,丢弃这次改名
|
||
if (profiles.some((p, i) => i !== target.col && p === trimmed))
|
||
return
|
||
const nextProfiles = [...profiles]
|
||
nextProfiles[target.col] = trimmed
|
||
const nextRows: ProfileRow[] = rows.map((r) => {
|
||
if (!(old in r.values))
|
||
return r
|
||
const v = r.values[old]
|
||
const nv = { ...r.values }
|
||
delete nv[old]
|
||
nv[trimmed] = v
|
||
return { ...r, values: nv }
|
||
})
|
||
onSave({ profiles: nextProfiles, rows: nextRows })
|
||
return
|
||
}
|
||
if (target.kind === 'key') {
|
||
const trimmed = newValue.trim()
|
||
if (!trimmed)
|
||
return
|
||
if (rows[target.row].key === trimmed)
|
||
return
|
||
const nextRows = rows.map((r, i) => i === target.row ? { ...r, key: trimmed } : r)
|
||
onSave({ profiles, rows: nextRows })
|
||
return
|
||
}
|
||
// cell
|
||
const currentVal = rows[target.row].values[target.profile] ?? ''
|
||
if (currentVal === newValue)
|
||
return
|
||
const nextRows = rows.map((r, i) => {
|
||
if (i !== target.row)
|
||
return r
|
||
const nv = { ...r.values, [target.profile]: newValue }
|
||
return { ...r, values: nv }
|
||
})
|
||
onSave({ profiles, rows: nextRows })
|
||
}, [profiles, rows, onSave])
|
||
|
||
const beginEdit = useCallback((target: EditTarget): void => {
|
||
let initial = ''
|
||
if (target.kind === 'header')
|
||
initial = profiles[target.col] ?? ''
|
||
else if (target.kind === 'key')
|
||
initial = rows[target.row]?.key ?? ''
|
||
else
|
||
initial = rows[target.row]?.values[target.profile] ?? ''
|
||
setDraft(initial)
|
||
setEditing(target)
|
||
}, [profiles, rows])
|
||
|
||
const cancelEdit = useCallback((): void => {
|
||
setEditing(null)
|
||
setDraft('')
|
||
}, [])
|
||
|
||
const finishEdit = useCallback((): void => {
|
||
if (!editing)
|
||
return
|
||
commitEdit(editing, draft)
|
||
setEditing(null)
|
||
setDraft('')
|
||
}, [editing, draft, commitEdit])
|
||
|
||
const addProfile = useCallback((): void => {
|
||
let n = profiles.length + 1
|
||
let name = `profile${n}`
|
||
while (profiles.includes(name)) {
|
||
n += 1
|
||
name = `profile${n}`
|
||
}
|
||
onSave({ profiles: [...profiles, name], rows })
|
||
}, [profiles, rows, onSave])
|
||
|
||
const deleteProfile = useCallback((col: number): void => {
|
||
if (profiles.length <= 1)
|
||
return
|
||
const name = profiles[col]
|
||
const nextProfiles = profiles.filter((_, i) => i !== col)
|
||
const nextRows = rows.map((r) => {
|
||
if (!(name in r.values))
|
||
return r
|
||
const nv = { ...r.values }
|
||
delete nv[name]
|
||
return { ...r, values: nv }
|
||
})
|
||
onSave({ profiles: nextProfiles, rows: nextRows })
|
||
}, [profiles, rows, onSave])
|
||
|
||
const addKey = useCallback((): void => {
|
||
let n = rows.length + 1
|
||
let key = `key${n}`
|
||
while (rows.some(r => r.key === key)) {
|
||
n += 1
|
||
key = `key${n}`
|
||
}
|
||
onSave({ profiles, rows: [...rows, { key, values: {} }] })
|
||
}, [profiles, rows, onSave])
|
||
|
||
const deleteKey = useCallback((row: number): void => {
|
||
onSave({ profiles, rows: rows.filter((_, i) => i !== row) })
|
||
}, [profiles, rows, onSave])
|
||
|
||
// CSS grid 列模板:第一列 key,后面是 profile 列,末列 add-profile
|
||
const gridTemplate = useMemo(() => {
|
||
const cols = ['minmax(160px, 200px)']
|
||
for (let i = 0; i < profiles.length; i++)
|
||
cols.push('minmax(200px, 1fr)')
|
||
cols.push('40px')
|
||
return cols.join(' ')
|
||
}, [profiles.length])
|
||
|
||
function openHeaderMenu(e: React.MouseEvent, col: number): void {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
setMenu({
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
items: [
|
||
{
|
||
label: '删除 profile',
|
||
danger: true,
|
||
onClick: () => {
|
||
if (profiles.length <= 1)
|
||
return
|
||
deleteProfile(col)
|
||
},
|
||
},
|
||
],
|
||
})
|
||
}
|
||
|
||
function openKeyMenu(e: React.MouseEvent, row: number): void {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
setMenu({
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
items: [
|
||
{
|
||
label: '删除 key',
|
||
danger: true,
|
||
onClick: () => deleteKey(row),
|
||
},
|
||
],
|
||
})
|
||
}
|
||
|
||
const cellBase
|
||
= 'min-h-[28px] border-r border-b border-[var(--vscode-panel-border)] text-xs flex items-center'
|
||
const editableHover
|
||
= 'hover:bg-[var(--vscode-list-hoverBackground)] cursor-text'
|
||
const headerBase
|
||
= 'min-h-[28px] border-r border-b border-[var(--vscode-panel-border)] text-xs font-medium flex items-center bg-[var(--vscode-editorWidget-background)]'
|
||
|
||
return (
|
||
<div className="relative flex h-full w-full flex-col overflow-auto bg-[var(--vscode-editor-background)] text-[var(--vscode-foreground)]">
|
||
<div className="min-w-full" style={{ display: 'grid', gridTemplateColumns: gridTemplate }}>
|
||
{/* Header row */}
|
||
<div
|
||
className={`${headerBase} border-l border-t px-2 text-[var(--vscode-descriptionForeground)]`}
|
||
>
|
||
key
|
||
</div>
|
||
{profiles.map((name, col) => (
|
||
<div
|
||
key={`h-${col}`}
|
||
className={`${headerBase} border-t px-2`}
|
||
onDoubleClick={() => beginEdit({ kind: 'header', col })}
|
||
onContextMenu={e => openHeaderMenu(e, col)}
|
||
title="双击改名,右键删除"
|
||
>
|
||
{editing && editing.kind === 'header' && editing.col === col
|
||
? (
|
||
<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)]"
|
||
/>
|
||
)
|
||
: (
|
||
<span className="truncate">{name}</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
<div className={`${headerBase} border-t flex items-center justify-center`}>
|
||
<button
|
||
type="button"
|
||
onClick={addProfile}
|
||
title="新增 profile 列"
|
||
aria-label="新增 profile 列"
|
||
className="grid size-5 place-items-center rounded opacity-60 hover:bg-[var(--vscode-list-hoverBackground)] hover:opacity-100"
|
||
>
|
||
<Plus className="size-3.5" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Data rows */}
|
||
{rows.map((row, rIdx) => (
|
||
<RowFragment
|
||
key={`row-${rIdx}`}
|
||
row={row}
|
||
rIdx={rIdx}
|
||
profiles={profiles}
|
||
editing={editing}
|
||
draft={draft}
|
||
inputRef={inputRef}
|
||
cellBase={cellBase}
|
||
editableHover={editableHover}
|
||
beginEdit={beginEdit}
|
||
cancelEdit={cancelEdit}
|
||
finishEdit={finishEdit}
|
||
setDraft={setDraft}
|
||
openKeyMenu={openKeyMenu}
|
||
onOpen={onOpen}
|
||
onEditCell={profile => setCellEdit({ row: rIdx, profile })}
|
||
/>
|
||
))}
|
||
|
||
{/* Add-key footer row spans all columns */}
|
||
<div
|
||
className={`${cellBase} border-l col-span-full justify-start px-2`}
|
||
style={{ gridColumn: `1 / span ${profiles.length + 2}` }}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={addKey}
|
||
title="新增 key 行"
|
||
className="flex items-center gap-1 rounded px-1 py-0.5 text-[var(--vscode-descriptionForeground)] hover:bg-[var(--vscode-list-hoverBackground)] hover:text-[var(--vscode-foreground)]"
|
||
>
|
||
<Plus className="size-3.5" />
|
||
<span>添加 key</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 自定义右键菜单 */}
|
||
{menu && (
|
||
<div
|
||
className="fixed z-50 min-w-[140px] rounded border border-[var(--vscode-menu-border,var(--vscode-panel-border))] bg-[var(--vscode-menu-background,var(--vscode-editorWidget-background))] py-1 text-xs shadow-lg"
|
||
style={{ left: menu.x, top: menu.y }}
|
||
onMouseDown={e => e.stopPropagation()}
|
||
>
|
||
{menu.items.map((it, i) => (
|
||
<button
|
||
key={i}
|
||
type="button"
|
||
onClick={() => {
|
||
it.onClick()
|
||
setMenu(null)
|
||
}}
|
||
className={`block w-full px-3 py-1 text-left hover:bg-[var(--vscode-menu-selectionBackground,var(--vscode-list-hoverBackground))] ${
|
||
it.danger ? 'text-state-red' : 'text-[var(--vscode-menu-foreground,var(--vscode-foreground))]'
|
||
}`}
|
||
>
|
||
{it.label}
|
||
</button>
|
||
))}
|
||
</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>
|
||
)
|
||
}
|
||
|
||
interface RowFragmentProps {
|
||
row: ProfileRow
|
||
rIdx: number
|
||
profiles: string[]
|
||
editing: EditTarget | null
|
||
draft: string
|
||
inputRef: React.MutableRefObject<HTMLInputElement | null>
|
||
cellBase: string
|
||
editableHover: string
|
||
beginEdit: (target: EditTarget) => void
|
||
cancelEdit: () => void
|
||
finishEdit: () => void
|
||
setDraft: (v: string) => void
|
||
openKeyMenu: (e: React.MouseEvent, row: number) => void
|
||
onOpen: (value: string) => void
|
||
onEditCell: (profile: string) => void
|
||
}
|
||
|
||
function RowFragment({
|
||
row,
|
||
rIdx,
|
||
profiles,
|
||
editing,
|
||
draft,
|
||
inputRef,
|
||
cellBase,
|
||
editableHover,
|
||
beginEdit,
|
||
cancelEdit,
|
||
finishEdit,
|
||
setDraft,
|
||
openKeyMenu,
|
||
onOpen,
|
||
onEditCell,
|
||
}: RowFragmentProps) {
|
||
return (
|
||
<>
|
||
<div
|
||
className={`${cellBase} ${editableHover} border-l px-2 font-medium`}
|
||
onDoubleClick={() => beginEdit({ kind: 'key', row: rIdx })}
|
||
onContextMenu={e => openKeyMenu(e, rIdx)}
|
||
title="双击编辑 key,右键删除"
|
||
>
|
||
{editing && editing.kind === 'key' && editing.row === rIdx
|
||
? (
|
||
<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)]"
|
||
/>
|
||
)
|
||
: (
|
||
<span className="truncate">{row.key || <span className="opacity-40">(空)</span>}</span>
|
||
)}
|
||
</div>
|
||
{profiles.map((profile, col) => {
|
||
const value = row.values[profile] ?? ''
|
||
// 折叠态只显示首行;多行/含图的值不再当 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={() => onEditCell(profile)}
|
||
title="双击编辑(多行 + 图片)"
|
||
>
|
||
<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>
|
||
)
|
||
})}
|
||
<div className={`${cellBase}`} />
|
||
</>
|
||
)
|
||
}
|