175 lines
6.1 KiB
TypeScript
175 lines
6.1 KiB
TypeScript
import type { ReleaseCommitGroup } from '../lib/messages'
|
|
import { X } from 'lucide-react'
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
|
|
interface Props {
|
|
open: boolean
|
|
branch: string
|
|
groups: ReleaseCommitGroup[]
|
|
onCancel: () => void
|
|
onConfirm: (shas: string[]) => void
|
|
}
|
|
|
|
function filterGroups(groups: ReleaseCommitGroup[], query: string): ReleaseCommitGroup[] {
|
|
const q = query.trim().toLowerCase()
|
|
if (!q)
|
|
return groups
|
|
const out: ReleaseCommitGroup[] = []
|
|
for (const g of groups) {
|
|
const head = `#${g.pr ?? ''} ${g.title} ${g.authors.join(' ')}`.toLowerCase()
|
|
if (head.includes(q)) {
|
|
out.push(g)
|
|
continue
|
|
}
|
|
const commits = g.commits.filter(c =>
|
|
`${c.subject} ${c.author} ${c.sha} ${c.date}`.toLowerCase().includes(q),
|
|
)
|
|
if (commits.length > 0)
|
|
out.push({ ...g, commits })
|
|
}
|
|
return out
|
|
}
|
|
|
|
export function CherryPickModal({ open, branch, groups, onCancel, onConfirm }: Props) {
|
|
const [query, setQuery] = useState('')
|
|
const [selected, setSelected] = useState<Set<string>>(() => new Set())
|
|
|
|
useEffect(() => {
|
|
if (!open)
|
|
return
|
|
setQuery('')
|
|
setSelected(new Set())
|
|
}, [open, groups])
|
|
|
|
useEffect(() => {
|
|
if (!open)
|
|
return
|
|
function onKey(e: KeyboardEvent): void {
|
|
if (e.key === 'Escape')
|
|
onCancel()
|
|
}
|
|
document.addEventListener('keydown', onKey)
|
|
return () => document.removeEventListener('keydown', onKey)
|
|
}, [open, onCancel])
|
|
|
|
const visible = useMemo(() => filterGroups(groups, query), [groups, query])
|
|
|
|
if (!open)
|
|
return null
|
|
|
|
function toggleSha(sha: string): void {
|
|
setSelected((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(sha))
|
|
next.delete(sha)
|
|
else
|
|
next.add(sha)
|
|
return next
|
|
})
|
|
}
|
|
|
|
function toggleGroup(g: ReleaseCommitGroup): void {
|
|
const shas = g.commits.map(c => c.sha)
|
|
const allOn = shas.every(s => selected.has(s))
|
|
setSelected((prev) => {
|
|
const next = new Set(prev)
|
|
for (const s of shas) {
|
|
if (allOn)
|
|
next.delete(s)
|
|
else
|
|
next.add(s)
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
const count = selected.size
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="cherry-pick-title"
|
|
className="flex max-h-[80vh] w-[min(640px,calc(100vw-2rem))] flex-col rounded border border-[var(--vscode-panel-border)] bg-[var(--vscode-editor-background)] text-sm shadow-lg"
|
|
>
|
|
<div className="flex items-center gap-2 border-b border-[var(--vscode-panel-border)] px-4 py-3">
|
|
<h2 id="cherry-pick-title" className="flex-1 font-medium">
|
|
{`摘修复到 ${branch}`}
|
|
</h2>
|
|
<button type="button" onClick={onCancel} aria-label="关闭" className="rounded p-1 hover:bg-white/10">
|
|
<X className="size-4" />
|
|
</button>
|
|
</div>
|
|
<div className="px-4 pt-3">
|
|
<input
|
|
value={query}
|
|
onChange={e => setQuery(e.target.value)}
|
|
placeholder="搜索作者、PR 号或标题"
|
|
className="w-full rounded border border-[var(--vscode-input-border,transparent)] bg-[var(--vscode-input-background)] px-2 py-1.5 text-xs outline-none"
|
|
/>
|
|
</div>
|
|
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto px-4 py-3">
|
|
{visible.length === 0
|
|
? <div className="py-8 text-center text-xs opacity-70">没有匹配的提交</div>
|
|
: visible.map(g => (
|
|
<section
|
|
key={g.pr ?? `ungrouped-${g.commits[0]?.sha}`}
|
|
className="rounded border border-[var(--vscode-panel-border)]"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleGroup(g)}
|
|
className="flex w-full items-center gap-2 border-b border-[var(--vscode-panel-border)] px-3 py-2 text-left hover:bg-white/5"
|
|
>
|
|
<span className="flex-1 truncate font-medium">
|
|
{g.pr != null ? `#${g.pr} ${g.title}` : g.title}
|
|
</span>
|
|
<span className="shrink-0 text-[11px] opacity-60">
|
|
{`${g.authors.join(' · ')} · ${g.commits.length} 条`}
|
|
</span>
|
|
</button>
|
|
<ul className="py-1">
|
|
{g.commits.map(c => (
|
|
<li key={c.sha}>
|
|
<label className="flex cursor-pointer items-start gap-2 px-3 py-1.5 hover:bg-white/5">
|
|
<input
|
|
type="checkbox"
|
|
checked={selected.has(c.sha)}
|
|
onChange={() => toggleSha(c.sha)}
|
|
className="mt-0.5"
|
|
/>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate">{c.subject}</span>
|
|
<span className="block text-[11px] opacity-60">
|
|
{`${c.sha.slice(0, 7)} · ${c.author} · ${c.date}`}
|
|
</span>
|
|
</span>
|
|
</label>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center justify-end gap-2 border-t border-[var(--vscode-panel-border)] px-4 py-3">
|
|
<span className="mr-auto text-xs opacity-70">
|
|
{`已选 ${count}`}
|
|
</span>
|
|
<button type="button" onClick={onCancel} className="rounded border border-[var(--vscode-panel-border)] px-3 py-1 text-xs hover:bg-white/10">
|
|
取消
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={count === 0}
|
|
onClick={() => onConfirm([...selected])}
|
|
className="rounded bg-[var(--vscode-button-background)] px-3 py-1 text-xs text-[var(--vscode-button-foreground)] disabled:opacity-40"
|
|
>
|
|
摘过去
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|