✨ feat(vscode): 摘修复按 PR 分组框选,可搜作者和标题
This commit is contained in:
1 parent
0031851df3
commit
3e3750c494
9 files changed
+353
-42
No files matched your search
@@ -2,6 +2,7 @@ import type { PastedImage } from './components/NewIssueModal'
|
||||
import type { Issue } from './types'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BottomTabs } from './components/BottomTabs'
|
||||
import { CherryPickModal } from './components/CherryPickModal'
|
||||
import { HandoffModal } from './components/HandoffModal'
|
||||
import { KanbanBoard } from './components/KanbanBoard'
|
||||
import { LogModal } from './components/LogModal'
|
||||
@@ -69,6 +70,9 @@ export function App() {
|
||||
hasChanges,
|
||||
release,
|
||||
runRelease,
|
||||
cherryPick,
|
||||
dismissCherryPick,
|
||||
confirmCherryPick,
|
||||
envLocked,
|
||||
envFileCount,
|
||||
envLockRunning,
|
||||
@@ -183,6 +187,8 @@ export function App() {
|
||||
return
|
||||
if (showLogs)
|
||||
return
|
||||
if (cherryPick !== null)
|
||||
return
|
||||
if (settingsOpen)
|
||||
return
|
||||
if (handoffIssue !== null || handoffSession !== null)
|
||||
@@ -285,7 +291,7 @@ export function App() {
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [state.status, showNewIssueModal, showLogs, settingsOpen, ordered, selectedId, selectedIssue, resumeSession])
|
||||
}, [state.status, showNewIssueModal, showLogs, settingsOpen, cherryPick, ordered, selectedId, selectedIssue, resumeSession])
|
||||
|
||||
// When the user is being forced into first-time setup, the kanban behind
|
||||
// the modal has nothing meaningful to show — replace the spinner with a
|
||||
@@ -491,6 +497,13 @@ export function App() {
|
||||
setHandoffSession(null)
|
||||
}}
|
||||
/>
|
||||
<CherryPickModal
|
||||
open={cherryPick !== null}
|
||||
branch={cherryPick?.branch ?? ''}
|
||||
groups={cherryPick?.groups ?? []}
|
||||
onCancel={dismissCherryPick}
|
||||
onConfirm={confirmCherryPick}
|
||||
/>
|
||||
<LogModal
|
||||
open={showLogs}
|
||||
entries={logs}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
import type { ToastItem } from '../components/ToastStack'
|
||||
import type { ExtensionToWebview, LogEntry } from '../lib/messages'
|
||||
import type { ExtensionToWebview, LogEntry, ReleaseCommitGroup } from '../lib/messages'
|
||||
import type { Issue, IssueColumn } from '../types'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { compareIssuesInColumn } from '../lib/issueSort'
|
||||
@@ -246,6 +246,10 @@ export interface UseIssuesResult {
|
||||
release: ReleaseStatus | undefined
|
||||
/** 发布按钮三个动作;running 由扩展侧状态决定,webview 不自置。 */
|
||||
runRelease: (action: ReleaseAction) => void
|
||||
/** 摘修复选单:扩展推来分组后的未发布提交。 */
|
||||
cherryPick: { branch: string, groups: ReleaseCommitGroup[] } | null
|
||||
dismissCherryPick: () => void
|
||||
confirmCherryPick: (shas: string[]) => void
|
||||
/**
|
||||
* Whether the workspace's `.env*` files are currently chmod-locked
|
||||
* according to `workspaceState`. Persisted per-workspace.
|
||||
@@ -336,6 +340,7 @@ export function useIssues(): UseIssuesResult {
|
||||
const [commitRunning, setCommitRunning] = useState<boolean>(false)
|
||||
const [hasChanges, setHasChanges] = useState<boolean>(false)
|
||||
const [release, setRelease] = useState<ReleaseStatus>()
|
||||
const [cherryPick, setCherryPick] = useState<{ branch: string, groups: ReleaseCommitGroup[] } | null>(null)
|
||||
const [envLocked, setEnvLocked] = useState<boolean>(false)
|
||||
const [envFileCount, setEnvFileCount] = useState<number>(0)
|
||||
const [envLockRunning, setEnvLockRunning] = useState<boolean>(false)
|
||||
@@ -656,6 +661,15 @@ export function useIssues(): UseIssuesResult {
|
||||
postMessage({ type: `release/${action}` })
|
||||
}, [])
|
||||
|
||||
const dismissCherryPick = useCallback((): void => {
|
||||
setCherryPick(null)
|
||||
}, [])
|
||||
|
||||
const confirmCherryPick = useCallback((shas: string[]): void => {
|
||||
setCherryPick(null)
|
||||
postMessage({ type: 'release/cherry-pick-confirm', shas })
|
||||
}, [])
|
||||
|
||||
const toggleEnvLock = useCallback((): void => {
|
||||
// Double-click guard — the next `env-lock/status` clears the running flag.
|
||||
if (envLockRunning)
|
||||
@@ -845,6 +859,9 @@ export function useIssues(): UseIssuesResult {
|
||||
case 'release/status':
|
||||
setRelease(msg)
|
||||
break
|
||||
case 'release/pick-show':
|
||||
setCherryPick({ branch: msg.branch, groups: msg.groups })
|
||||
break
|
||||
case 'env-lock/status': {
|
||||
setEnvLocked(msg.locked)
|
||||
setEnvFileCount(msg.fileCount)
|
||||
@@ -873,5 +890,5 @@ export function useIssues(): UseIssuesResult {
|
||||
return cleanup
|
||||
}, [clearPrDiffSummaryRunning, markHandoffRunning])
|
||||
|
||||
return { state, settings, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startTestSession, resumeTestSession, focusSession, openFile, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, startBrainstormSession, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, updateIssueTestProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, release, runRelease, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning }
|
||||
return { state, settings, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startTestSession, resumeTestSession, focusSession, openFile, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, startBrainstormSession, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, updateIssueTestProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, release, runRelease, cherryPick, dismissCherryPick, confirmCherryPick, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning }
|
||||
}
|
||||
@@ -23,6 +23,21 @@ export interface PrFile {
|
||||
previousFilename?: string
|
||||
}
|
||||
|
||||
export interface ReleaseCommit {
|
||||
sha: string
|
||||
subject: string
|
||||
author: string
|
||||
date: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export interface ReleaseCommitGroup {
|
||||
pr?: number
|
||||
title: string
|
||||
authors: string[]
|
||||
commits: ReleaseCommit[]
|
||||
}
|
||||
|
||||
export type ToastLevel = 'info' | 'success' | 'error'
|
||||
|
||||
export interface SessionHandoffPendingItem {
|
||||
@@ -137,6 +152,7 @@ export type ExtensionToWebview
|
||||
running: boolean
|
||||
jenkinsConfigured: boolean
|
||||
}
|
||||
| { type: 'release/pick-show', branch: string, groups: ReleaseCommitGroup[] }
|
||||
| { type: 'env-lock/status', locked: boolean, fileCount: number, failedCount?: number }
|
||||
| { type: 'issue/remove', issueNumber: number }
|
||||
| { type: 'profiles/show', data: ProfilesData }
|
||||
@@ -225,6 +241,7 @@ export type WebviewToExtension
|
||||
| { type: 'release/check' }
|
||||
| { type: 'release/cut' }
|
||||
| { type: 'release/cherry-pick' }
|
||||
| { type: 'release/cherry-pick-confirm', shas: string[] }
|
||||
| { type: 'release/deploy' }
|
||||
| { type: 'env-lock/check' }
|
||||
| { type: 'env-lock/toggle' }
|
||||
|
||||
Reference in new issue
Block a user