This commit is contained in:
cruldra committed 2026-06-23 05:02:15 +08:00
commit e6f1776d4f
264 files changed
+54215

No files matched your search

@@ -0,0 +1,498 @@
/**
* 工作区 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, Plus } from 'lucide-react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
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 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)
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}
/>
))}
{/* 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>
)}
</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
}
function RowFragment({
row,
rIdx,
profiles,
editing,
draft,
inputRef,
cellBase,
editableHover,
beginEdit,
cancelEdit,
finishEdit,
setDraft,
openKeyMenu,
onOpen,
}: 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] ?? ''
const isEditing = editing && editing.kind === 'cell' && editing.row === rIdx && editing.profile === profile
const kind = classifyValue(value)
return (
<div
key={`r${rIdx}-c${col}`}
className={`${cellBase} ${editableHover} group px-2`}
onDoubleClick={() => beginEdit({ kind: 'cell', row: rIdx, 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>
)
})}
<div className={`${cellBase}`} />
</>
)
}