Files
superwork/vscode/webview-ui/src/App.tsx
T
cruldraandClaude Opus 4.7 cacc60eb0e feat(vscode): worktree 打开方式可选 VS Code/PyCharm,并修复 handleOpenWorktree 不展开 ~ 的打开报错
- 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>
2026-06-24 05:20:22 +08:00

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>
)
}