- worktree.ts: handleOpenWorktree/handleDeleteWorktree/dispatchImplTabPostCloseAsync 三处改用 resolveWorktreePath,修复带 ~ 的存量路径被拼成 <工作区>/~/... 导致打不开 - 新增全局设置 worktreeOpenWith('vscode' | 'pycharm',默认 vscode),贯穿 store/settings handler/messages/KanbanPanel 与 webview 的 messages/useIssues/App/SettingsModal「钩子」分组新增「工作树打开方式」下拉 - handleOpenWorktree 按设置分支:pycharm 走 JetBrains pycharm 启动器,ENOENT 时提示启用命令行启动器 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
467 lines
17 KiB
TypeScript
467 lines
17 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { BottomTabs } from './components/BottomTabs'
|
|
import { KanbanBoard } from './components/KanbanBoard'
|
|
import { LogModal } from './components/LogModal'
|
|
import type { PastedImage } from './components/NewIssueModal'
|
|
import { NewIssueModal } from './components/NewIssueModal'
|
|
import { PanelHeader } from './components/PanelHeader'
|
|
import { SettingsModal } from './components/SettingsModal'
|
|
import { ToastStack } from './components/ToastStack'
|
|
import { useIssues } from './hooks/useIssues'
|
|
import { useManagedSessions } from './hooks/useManagedSessions'
|
|
import { useProfiles } from './hooks/useProfiles'
|
|
import { compareIssuesInColumn } from './lib/issueSort'
|
|
import type { Issue } from './types'
|
|
import { COLUMN_ORDER } from './types'
|
|
|
|
export function App() {
|
|
const {
|
|
state,
|
|
settings,
|
|
globalAutoReview,
|
|
youtrackConfigured,
|
|
importYouTrack,
|
|
toasts,
|
|
profiles,
|
|
setIssues,
|
|
refresh,
|
|
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,
|
|
updateIssueTestProfilePath,
|
|
logs,
|
|
clearLogs,
|
|
pendingSelectId,
|
|
clearPendingSelect,
|
|
commitRunning,
|
|
runCommit,
|
|
hasChanges,
|
|
branchSyncBehind,
|
|
branchSyncRunning,
|
|
branchSyncDisabled,
|
|
branchSyncTitle,
|
|
runBranchSync,
|
|
envLocked,
|
|
envFileCount,
|
|
envLockRunning,
|
|
envLockTitle,
|
|
toggleEnvLock,
|
|
} = useIssues()
|
|
const { profiles: profileData, saveProfiles, openProfileValue } = useProfiles()
|
|
const {
|
|
managedSessions,
|
|
createManagedSession,
|
|
renameManagedSession,
|
|
resumeManagedSession,
|
|
deleteManagedSession,
|
|
closeManagedSessionTab,
|
|
} = useManagedSessions()
|
|
const [showNewIssueModal, setShowNewIssueModal] = useState(false)
|
|
const [showLogs, setShowLogs] = useState(false)
|
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
// 程序化设置的选中(反向选中 / pendingSelectId)记在这里,让下面的自动聚焦
|
|
// effect 跳过它——否则 选中→聚焦→终端激活→反向选中 会死循环、CPU 飙升。
|
|
const lastProgrammaticSelectRef = useRef<string | null>(null)
|
|
|
|
function handleSubmitNewIssue(userRequest: string, images: PastedImage[], profilePath?: string): void {
|
|
const payload = images.map(({ mediaType, base64 }) => ({ mediaType, base64 }))
|
|
createIssue(userRequest, payload.length > 0 ? payload : undefined, profilePath)
|
|
setShowNewIssueModal(false)
|
|
}
|
|
|
|
// Drop selectedId if it's no longer present after issues refresh.
|
|
const readyIssues = state.status === 'ready' ? state.issues : null
|
|
useEffect(() => {
|
|
if (!readyIssues)
|
|
return
|
|
if (selectedId && !readyIssues.some(i => i.id === selectedId))
|
|
setSelectedId(null)
|
|
}, [readyIssues, selectedId])
|
|
|
|
// Auto-select issues that arrive via webhook in response to user-initiated
|
|
// creation (issue/append with select: true). User-initiated, so we
|
|
// intentionally override any current arrow-key selection.
|
|
useEffect(() => {
|
|
if (!pendingSelectId)
|
|
return
|
|
if (!readyIssues)
|
|
return
|
|
if (!readyIssues.some(i => i.id === pendingSelectId))
|
|
return
|
|
lastProgrammaticSelectRef.current = pendingSelectId
|
|
setSelectedId(pendingSelectId)
|
|
clearPendingSelect()
|
|
}, [pendingSelectId, readyIssues, clearPendingSelect])
|
|
|
|
// Issues partitioned by column, in COLUMN_ORDER. Used for arrow navigation.
|
|
// 排序与看板视觉一致:完成列按 PR 合并时间降序,其余列按工单号降序。
|
|
const ordered = useMemo<Issue[][]>(() => {
|
|
if (!readyIssues)
|
|
return []
|
|
return COLUMN_ORDER.map(col =>
|
|
readyIssues.filter(i => i.column === col).sort((a, b) => compareIssuesInColumn(col, a, b)),
|
|
)
|
|
}, [readyIssues])
|
|
|
|
// Resolved selected issue (or null) for the detail panel.
|
|
const selectedIssue = useMemo<Issue | null>(() => {
|
|
if (!readyIssues || !selectedId)
|
|
return null
|
|
return readyIssues.find(i => i.id === selectedId) ?? null
|
|
}, [readyIssues, selectedId])
|
|
|
|
// Auto-focus the terminal tab whenever the selected card has a session.
|
|
// 优先 implementSessionId(实施终端),没有再回退到 sessionId(规划/头脑风暴终端);
|
|
// 依赖里加入 implementSessionId,让"先有规划、后有实施"的场景能正确切换。
|
|
useEffect(() => {
|
|
if (!selectedIssue)
|
|
return
|
|
// 这次选中是程序化设置的(反向选中等)→ 不聚焦终端,打断死循环。仅用户主动
|
|
// 点卡片/方向键切换才走聚焦。按 id 匹配,避免同一工单反向选中后残留的标记
|
|
// 误压制后续的用户选中。
|
|
if (selectedIssue.id === lastProgrammaticSelectRef.current) {
|
|
lastProgrammaticSelectRef.current = null
|
|
return
|
|
}
|
|
if (selectedIssue.implementSessionId || selectedIssue.sessionId)
|
|
focusSession(selectedIssue.number)
|
|
}, [selectedIssue?.implementSessionId, selectedIssue?.sessionId, selectedIssue?.number, focusSession])
|
|
|
|
// Global keyboard navigation. Skipped whenever an overlay (new-issue, logs,
|
|
// settings) is showing, so its inputs receive arrow/Enter without
|
|
// interference.
|
|
const settingsOpen = settings !== null
|
|
useEffect(() => {
|
|
if (state.status !== 'ready')
|
|
return
|
|
if (showNewIssueModal)
|
|
return
|
|
if (showLogs)
|
|
return
|
|
if (settingsOpen)
|
|
return
|
|
|
|
function onKeyDown(e: KeyboardEvent): void {
|
|
// Skip when typing in form fields.
|
|
const active = document.activeElement
|
|
const tag = active?.tagName
|
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT')
|
|
return
|
|
// Editable elements (e.g. contenteditable divs).
|
|
if (active && (active as HTMLElement).isContentEditable)
|
|
return
|
|
// 焦点在提交列表(role=listbox)等自管键盘的区域时,让它自己处理方向键。
|
|
if (active && (active as HTMLElement).closest?.('[role="listbox"]'))
|
|
return
|
|
|
|
if (e.key === 'Escape') {
|
|
if (selectedId !== null) {
|
|
e.preventDefault()
|
|
setSelectedId(null)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (e.key === 'Enter') {
|
|
const sid = selectedIssue?.implementSessionId || selectedIssue?.sessionId
|
|
if (sid) {
|
|
e.preventDefault()
|
|
const isImpl = !!selectedIssue?.implementSessionId
|
|
const cwd = isImpl ? selectedIssue?.worktreePath : undefined
|
|
resumeSession(sid, selectedIssue?.profilePath, cwd, selectedIssue?.number)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'ArrowUp' && e.key !== 'ArrowDown')
|
|
return
|
|
|
|
// Locate current selection within `ordered`.
|
|
let colIdx = -1
|
|
let rowIdx = -1
|
|
if (selectedId) {
|
|
for (let c = 0; c < ordered.length; c++) {
|
|
const r = ordered[c].findIndex(i => i.id === selectedId)
|
|
if (r >= 0) {
|
|
colIdx = c
|
|
rowIdx = r
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// No current selection: pick the first card in the first non-empty
|
|
// column (typically todo[0]).
|
|
if (colIdx < 0) {
|
|
for (let c = 0; c < ordered.length; c++) {
|
|
if (ordered[c].length > 0) {
|
|
e.preventDefault()
|
|
setSelectedId(ordered[c][0].id)
|
|
return
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if (e.key === 'ArrowUp') {
|
|
if (rowIdx > 0) {
|
|
e.preventDefault()
|
|
setSelectedId(ordered[colIdx][rowIdx - 1].id)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
if (rowIdx < ordered[colIdx].length - 1) {
|
|
e.preventDefault()
|
|
setSelectedId(ordered[colIdx][rowIdx + 1].id)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Horizontal navigation: keep rowIdx, clamp to target column length - 1.
|
|
// Skip empty columns entirely.
|
|
const dir = e.key === 'ArrowLeft' ? -1 : 1
|
|
let target = colIdx + dir
|
|
while (target >= 0 && target < ordered.length && ordered[target].length === 0)
|
|
target += dir
|
|
if (target < 0 || target >= ordered.length)
|
|
return
|
|
e.preventDefault()
|
|
const clampedRow = Math.min(rowIdx, ordered[target].length - 1)
|
|
setSelectedId(ordered[target][clampedRow].id)
|
|
}
|
|
|
|
window.addEventListener('keydown', onKeyDown)
|
|
return () => window.removeEventListener('keydown', onKeyDown)
|
|
}, [state.status, showNewIssueModal, showLogs, settingsOpen, 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
|
|
// muted hint so it doesn't look like things are still loading.
|
|
const blockedBySetup = settings !== null && !settings.canCancel && state.status === 'loading'
|
|
|
|
return (
|
|
<div className="flex h-screen w-screen flex-col overflow-hidden bg-[var(--vscode-editor-background)]">
|
|
{state.status === 'loading' && (
|
|
<>
|
|
<PanelHeader
|
|
onRefresh={refresh}
|
|
onEditAuth={requestEditAuth}
|
|
commitRunning={commitRunning}
|
|
hasChanges={hasChanges}
|
|
onCommit={runCommit}
|
|
branchSyncBehind={branchSyncBehind}
|
|
branchSyncRunning={branchSyncRunning}
|
|
branchSyncDisabled={branchSyncDisabled}
|
|
branchSyncTitle={branchSyncTitle}
|
|
envLocked={envLocked}
|
|
envFileCount={envFileCount}
|
|
envLockRunning={envLockRunning}
|
|
envLockTitle={envLockTitle}
|
|
onToggleEnvLock={toggleEnvLock}
|
|
onSyncBranch={runBranchSync}
|
|
youtrackConfigured={youtrackConfigured}
|
|
onImportYouTrack={importYouTrack}
|
|
/>
|
|
<div className="flex h-full w-full items-center justify-center text-sm opacity-70">
|
|
{blockedBySetup ? '请先完成设置' : '加载中…'}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{state.status === 'ready' && (
|
|
<>
|
|
<PanelHeader
|
|
onRefresh={refresh}
|
|
onEditAuth={requestEditAuth}
|
|
commitRunning={commitRunning}
|
|
hasChanges={hasChanges}
|
|
onCommit={runCommit}
|
|
branchSyncBehind={branchSyncBehind}
|
|
branchSyncRunning={branchSyncRunning}
|
|
branchSyncDisabled={branchSyncDisabled}
|
|
branchSyncTitle={branchSyncTitle}
|
|
envLocked={envLocked}
|
|
envFileCount={envFileCount}
|
|
envLockRunning={envLockRunning}
|
|
envLockTitle={envLockTitle}
|
|
onToggleEnvLock={toggleEnvLock}
|
|
onSyncBranch={runBranchSync}
|
|
youtrackConfigured={youtrackConfigured}
|
|
onImportYouTrack={importYouTrack}
|
|
/>
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
<div className="min-h-0 flex-1 overflow-hidden">
|
|
<KanbanBoard
|
|
issues={state.issues}
|
|
onIssuesChange={setIssues}
|
|
onCreateIssue={() => setShowNewIssueModal(true)}
|
|
selectedId={selectedId}
|
|
onSelectIssue={setSelectedId}
|
|
onColumnChange={changeColumn}
|
|
onDependencySet={setDependency}
|
|
onDependencyClear={clearDependency}
|
|
/>
|
|
</div>
|
|
<div className="h-[40vh] shrink-0">
|
|
<BottomTabs
|
|
issue={selectedIssue}
|
|
allIssues={state.issues}
|
|
globalAutoReview={globalAutoReview}
|
|
onOpenInBrowser={openUrl}
|
|
onResumeSession={resumeSession}
|
|
onResumeReviewSession={resumeReviewSession}
|
|
onStartTestSession={startTestSession}
|
|
onResumeTestSession={resumeTestSession}
|
|
onOpenFile={openFile}
|
|
onImplement={implement}
|
|
onOpenPr={openPr}
|
|
onGeneratePrDiffSummary={generatePrDiffSummary}
|
|
isPrDiffSummaryRunning={isPrDiffSummaryRunning}
|
|
onOpenWorktree={openWorktree}
|
|
onDeleteWorktree={deleteWorktree}
|
|
onMergeBranch={mergeBranch}
|
|
onDeleteIssue={deleteIssue}
|
|
onCloseIssue={closeIssue}
|
|
onCloseSessionTab={closeSessionTab}
|
|
onStartBrainstormSession={startBrainstormSession}
|
|
onUpdateAutoReview={updateIssueAutoReview}
|
|
onUpdateProfilePath={updateIssueProfilePath}
|
|
onUpdateTestProfilePath={updateIssueTestProfilePath}
|
|
onOpenLogs={() => setShowLogs(true)}
|
|
profileData={profileData}
|
|
onProfileSave={saveProfiles}
|
|
onProfileOpen={openProfileValue}
|
|
managedSessions={managedSessions}
|
|
profiles={profiles}
|
|
onManagedSessionCreate={createManagedSession}
|
|
onManagedSessionRename={renameManagedSession}
|
|
onManagedSessionResume={resumeManagedSession}
|
|
onManagedSessionDelete={deleteManagedSession}
|
|
onManagedSessionCloseTab={closeManagedSessionTab}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{state.status === 'error' && (
|
|
<>
|
|
<PanelHeader
|
|
onRefresh={refresh}
|
|
onEditAuth={requestEditAuth}
|
|
commitRunning={commitRunning}
|
|
hasChanges={hasChanges}
|
|
onCommit={runCommit}
|
|
branchSyncBehind={branchSyncBehind}
|
|
branchSyncRunning={branchSyncRunning}
|
|
branchSyncDisabled={branchSyncDisabled}
|
|
branchSyncTitle={branchSyncTitle}
|
|
envLocked={envLocked}
|
|
envFileCount={envFileCount}
|
|
envLockRunning={envLockRunning}
|
|
envLockTitle={envLockTitle}
|
|
onToggleEnvLock={toggleEnvLock}
|
|
onSyncBranch={runBranchSync}
|
|
youtrackConfigured={youtrackConfigured}
|
|
onImportYouTrack={importYouTrack}
|
|
/>
|
|
<div className="flex flex-1 items-center justify-center p-6">
|
|
<div className="max-w-md rounded-md border border-state-red/40 bg-state-red/10 p-4 text-sm">
|
|
<div className="mb-3 font-medium text-state-red">无法加载 issues</div>
|
|
<div className="mb-4 whitespace-pre-wrap opacity-90">{state.message}</div>
|
|
<div className="flex gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={refresh}
|
|
className="rounded border border-state-red/60 px-3 py-1 text-xs hover:bg-state-red/20"
|
|
>
|
|
重试
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={requestEditAuth}
|
|
className="text-xs text-[var(--vscode-textLink-foreground)] underline"
|
|
>
|
|
重新配置
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<NewIssueModal
|
|
open={showNewIssueModal}
|
|
onCancel={() => setShowNewIssueModal(false)}
|
|
onSubmit={handleSubmitNewIssue}
|
|
profiles={profiles}
|
|
defaultProfileName="offical"
|
|
/>
|
|
<LogModal
|
|
open={showLogs}
|
|
entries={logs}
|
|
onClose={() => setShowLogs(false)}
|
|
onClear={clearLogs}
|
|
/>
|
|
<SettingsModal
|
|
open={settings !== null}
|
|
host={settings?.host ?? ''}
|
|
errorMessage={settings?.errorMessage}
|
|
canCancel={settings?.canCancel}
|
|
initialTokenSaved={settings?.tokenSaved ?? false}
|
|
initialWebhookPort={settings?.webhookPort ?? 17421}
|
|
initialBrainstormPrompt={settings?.brainstormPrompt ?? ''}
|
|
initialImplementPlanPrompt={settings?.implementPlanPrompt ?? ''}
|
|
initialAutoReview={settings?.autoReview ?? true}
|
|
initialReviewPrompt={settings?.reviewPrompt ?? ''}
|
|
initialDevBranch={settings?.devBranch ?? 'main'}
|
|
initialAutoBuildBranch={settings?.autoBuildBranch ?? ''}
|
|
initialWorktreeDirectory={settings?.worktreeDirectory ?? ''}
|
|
initialWorktreeOpenWith={settings?.worktreeOpenWith ?? 'vscode'}
|
|
initialWorktreePostCreateScript={settings?.worktreePostCreateScript ?? ''}
|
|
initialWorktreePreRemoveScript={settings?.worktreePreRemoveScript ?? ''}
|
|
initialImplTabPreCreateScript={settings?.implTabPreCreateScript ?? ''}
|
|
initialImplTabPostCloseScript={settings?.implTabPostCloseScript ?? ''}
|
|
initialYoutrackBaseUrl={settings?.youtrackBaseUrl ?? ''}
|
|
initialYoutrackProjectShortName={settings?.youtrackProjectShortName ?? ''}
|
|
initialYoutrackCloseCommand={settings?.youtrackCloseCommand ?? ''}
|
|
initialYoutrackTokenSaved={settings?.youtrackTokenSaved ?? false}
|
|
youtrackProjects={youtrackProjects}
|
|
onListYouTrackProjects={listYouTrackProjects}
|
|
onSubmit={saveSettings}
|
|
onCancel={settings?.canCancel ? dismissSettings : undefined}
|
|
/>
|
|
<ToastStack toasts={toasts} onDismiss={dismissToast} onOpenUrl={openUrl} />
|
|
</div>
|
|
)
|
|
}
|