mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
300 lines
9.9 KiB
TypeScript
300 lines
9.9 KiB
TypeScript
import React, { useEffect, useState } from 'react'
|
|
import type { CommandResultDisplay } from 'src/commands.js'
|
|
import { logEvent } from 'src/services/analytics/index.js'
|
|
import { logForDebugging } from 'src/utils/debug.js'
|
|
import { Box, Text, Dialog } from '@anthropic/ink'
|
|
import { execFileNoThrow } from '../utils/execFileNoThrow.js'
|
|
import { getPlansDirectory } from '../utils/plans.js'
|
|
import { setCwd } from '../utils/Shell.js'
|
|
import {
|
|
cleanupWorktree,
|
|
getCurrentWorktreeSession,
|
|
keepWorktree,
|
|
killTmuxSession,
|
|
} from '../utils/worktree.js'
|
|
import { Select } from './CustomSelect/select.js'
|
|
import { Spinner } from './Spinner.js'
|
|
|
|
// Inline require breaks the cycle this file would otherwise close:
|
|
// sessionStorage → commands → exit → ExitFlow → here. All call sites
|
|
// are inside callbacks, so the lazy require never sees an undefined import.
|
|
function recordWorktreeExit(): void {
|
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
;(
|
|
require('../utils/sessionStorage.js') as typeof import('../utils/sessionStorage.js')
|
|
).saveWorktreeState(null)
|
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
|
}
|
|
|
|
type Props = {
|
|
onDone: (
|
|
result?: string,
|
|
options?: { display?: CommandResultDisplay },
|
|
) => void
|
|
onCancel?: () => void
|
|
}
|
|
|
|
export function WorktreeExitDialog({
|
|
onDone,
|
|
onCancel,
|
|
}: Props): React.ReactNode {
|
|
const [status, setStatus] = useState<
|
|
'loading' | 'asking' | 'keeping' | 'removing' | 'done'
|
|
>('loading')
|
|
const [changes, setChanges] = useState<string[]>([])
|
|
const [commitCount, setCommitCount] = useState<number>(0)
|
|
const [resultMessage, setResultMessage] = useState<string | undefined>()
|
|
const worktreeSession = getCurrentWorktreeSession()
|
|
|
|
useEffect(() => {
|
|
async function loadChanges() {
|
|
let changeLines: string[] = []
|
|
const gitStatus = await execFileNoThrow('git', ['status', '--porcelain'])
|
|
if (gitStatus.stdout) {
|
|
changeLines = gitStatus.stdout.split('\n').filter(_ => _.trim() !== '')
|
|
setChanges(changeLines)
|
|
}
|
|
|
|
// Check for commits to eject
|
|
if (worktreeSession) {
|
|
// Get commits in worktree that are not in original branch
|
|
const { stdout: commitsStr } = await execFileNoThrow('git', [
|
|
'rev-list',
|
|
'--count',
|
|
`${worktreeSession.originalHeadCommit}..HEAD`,
|
|
])
|
|
const count = parseInt(commitsStr.trim()) || 0
|
|
setCommitCount(count)
|
|
|
|
// If no changes and no commits, clean up silently
|
|
if (changeLines.length === 0 && count === 0) {
|
|
setStatus('removing')
|
|
void cleanupWorktree()
|
|
.then(() => {
|
|
process.chdir(worktreeSession.originalCwd)
|
|
setCwd(worktreeSession.originalCwd)
|
|
recordWorktreeExit()
|
|
getPlansDirectory.cache.clear?.()
|
|
setResultMessage('Worktree removed (no changes)')
|
|
})
|
|
.catch(error => {
|
|
logForDebugging(`Failed to clean up worktree: ${error}`, {
|
|
level: 'error',
|
|
})
|
|
setResultMessage('Worktree cleanup failed, exiting anyway')
|
|
})
|
|
.then(() => {
|
|
setStatus('done')
|
|
})
|
|
return
|
|
} else {
|
|
setStatus('asking')
|
|
}
|
|
}
|
|
}
|
|
void loadChanges()
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
|
|
}, [worktreeSession])
|
|
|
|
useEffect(() => {
|
|
if (status === 'done') {
|
|
onDone(resultMessage)
|
|
}
|
|
}, [status, onDone, resultMessage])
|
|
|
|
if (!worktreeSession) {
|
|
onDone('No active worktree session found', { display: 'system' })
|
|
return null
|
|
}
|
|
|
|
if (status === 'loading' || status === 'done') {
|
|
return null
|
|
}
|
|
|
|
async function handleSelect(value: string) {
|
|
if (!worktreeSession) return
|
|
|
|
const hasTmux = Boolean(worktreeSession.tmuxSessionName)
|
|
|
|
if (value === 'keep' || value === 'keep-with-tmux') {
|
|
setStatus('keeping')
|
|
logEvent('tengu_worktree_kept', {
|
|
commits: commitCount,
|
|
changed_files: changes.length,
|
|
})
|
|
await keepWorktree()
|
|
process.chdir(worktreeSession.originalCwd)
|
|
setCwd(worktreeSession.originalCwd)
|
|
recordWorktreeExit()
|
|
getPlansDirectory.cache.clear?.()
|
|
if (hasTmux) {
|
|
setResultMessage(
|
|
`Worktree kept. Your work is saved at ${worktreeSession.worktreePath} on branch ${worktreeSession.worktreeBranch}. Reattach to tmux session with: tmux attach -t ${worktreeSession.tmuxSessionName}`,
|
|
)
|
|
} else {
|
|
setResultMessage(
|
|
`Worktree kept. Your work is saved at ${worktreeSession.worktreePath} on branch ${worktreeSession.worktreeBranch}`,
|
|
)
|
|
}
|
|
setStatus('done')
|
|
} else if (value === 'keep-kill-tmux') {
|
|
setStatus('keeping')
|
|
logEvent('tengu_worktree_kept', {
|
|
commits: commitCount,
|
|
changed_files: changes.length,
|
|
})
|
|
if (worktreeSession.tmuxSessionName) {
|
|
await killTmuxSession(worktreeSession.tmuxSessionName)
|
|
}
|
|
await keepWorktree()
|
|
process.chdir(worktreeSession.originalCwd)
|
|
setCwd(worktreeSession.originalCwd)
|
|
recordWorktreeExit()
|
|
getPlansDirectory.cache.clear?.()
|
|
setResultMessage(
|
|
`Worktree kept at ${worktreeSession.worktreePath} on branch ${worktreeSession.worktreeBranch}. Tmux session terminated.`,
|
|
)
|
|
setStatus('done')
|
|
} else if (value === 'remove' || value === 'remove-with-tmux') {
|
|
setStatus('removing')
|
|
logEvent('tengu_worktree_removed', {
|
|
commits: commitCount,
|
|
changed_files: changes.length,
|
|
})
|
|
if (worktreeSession.tmuxSessionName) {
|
|
await killTmuxSession(worktreeSession.tmuxSessionName)
|
|
}
|
|
try {
|
|
await cleanupWorktree()
|
|
process.chdir(worktreeSession.originalCwd)
|
|
setCwd(worktreeSession.originalCwd)
|
|
recordWorktreeExit()
|
|
getPlansDirectory.cache.clear?.()
|
|
} catch (error) {
|
|
logForDebugging(`Failed to clean up worktree: ${error}`, {
|
|
level: 'error',
|
|
})
|
|
setResultMessage('Worktree cleanup failed, exiting anyway')
|
|
setStatus('done')
|
|
return
|
|
}
|
|
const tmuxNote = hasTmux ? ' Tmux session terminated.' : ''
|
|
if (commitCount > 0 && changes.length > 0) {
|
|
setResultMessage(
|
|
`Worktree removed. ${commitCount} ${commitCount === 1 ? 'commit' : 'commits'} and uncommitted changes were discarded.${tmuxNote}`,
|
|
)
|
|
} else if (commitCount > 0) {
|
|
setResultMessage(
|
|
`Worktree removed. ${commitCount} ${commitCount === 1 ? 'commit' : 'commits'} on ${worktreeSession.worktreeBranch} ${commitCount === 1 ? 'was' : 'were'} discarded.${tmuxNote}`,
|
|
)
|
|
} else if (changes.length > 0) {
|
|
setResultMessage(
|
|
`Worktree removed. Uncommitted changes were discarded.${tmuxNote}`,
|
|
)
|
|
} else {
|
|
setResultMessage(`Worktree removed.${tmuxNote}`)
|
|
}
|
|
setStatus('done')
|
|
}
|
|
}
|
|
|
|
if (status === 'keeping') {
|
|
return (
|
|
<Box flexDirection="row" marginY={1}>
|
|
<Spinner />
|
|
<Text>Keeping worktree…</Text>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
if (status === 'removing') {
|
|
return (
|
|
<Box flexDirection="row" marginY={1}>
|
|
<Spinner />
|
|
<Text>Removing worktree…</Text>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
const branchName = worktreeSession.worktreeBranch
|
|
const hasUncommitted = changes.length > 0
|
|
const hasCommits = commitCount > 0
|
|
|
|
let subtitle = ''
|
|
if (hasUncommitted && hasCommits) {
|
|
subtitle = `You have ${changes.length} uncommitted ${changes.length === 1 ? 'file' : 'files'} and ${commitCount} ${commitCount === 1 ? 'commit' : 'commits'} on ${branchName}. All will be lost if you remove.`
|
|
} else if (hasUncommitted) {
|
|
subtitle = `You have ${changes.length} uncommitted ${changes.length === 1 ? 'file' : 'files'}. These will be lost if you remove the worktree.`
|
|
} else if (hasCommits) {
|
|
subtitle = `You have ${commitCount} ${commitCount === 1 ? 'commit' : 'commits'} on ${branchName}. The branch will be deleted if you remove the worktree.`
|
|
} else {
|
|
subtitle =
|
|
'You are working in a worktree. Keep it to continue working there, or remove it to clean up.'
|
|
}
|
|
|
|
function handleCancel() {
|
|
if (onCancel) {
|
|
// Abort exit and return to the session
|
|
onCancel()
|
|
return
|
|
}
|
|
// Fallback: treat Escape as "keep" if no onCancel provided
|
|
void handleSelect('keep')
|
|
}
|
|
|
|
const removeDescription =
|
|
hasUncommitted || hasCommits
|
|
? 'All changes and commits will be lost.'
|
|
: 'Clean up the worktree directory.'
|
|
|
|
const hasTmuxSession = Boolean(worktreeSession.tmuxSessionName)
|
|
|
|
const options = hasTmuxSession
|
|
? [
|
|
{
|
|
label: 'Keep worktree and tmux session',
|
|
value: 'keep-with-tmux',
|
|
description: `Stays at ${worktreeSession.worktreePath}. Reattach with: tmux attach -t ${worktreeSession.tmuxSessionName}`,
|
|
},
|
|
{
|
|
label: 'Keep worktree, kill tmux session',
|
|
value: 'keep-kill-tmux',
|
|
description: `Keeps worktree at ${worktreeSession.worktreePath}, terminates tmux session.`,
|
|
},
|
|
{
|
|
label: 'Remove worktree and tmux session',
|
|
value: 'remove-with-tmux',
|
|
description: removeDescription,
|
|
},
|
|
]
|
|
: [
|
|
{
|
|
label: 'Keep worktree',
|
|
value: 'keep',
|
|
description: `Stays at ${worktreeSession.worktreePath}`,
|
|
},
|
|
{
|
|
label: 'Remove worktree',
|
|
value: 'remove',
|
|
description: removeDescription,
|
|
},
|
|
]
|
|
|
|
const defaultValue = hasTmuxSession ? 'keep-with-tmux' : 'keep'
|
|
|
|
return (
|
|
<Dialog
|
|
title="Exiting worktree session"
|
|
subtitle={subtitle}
|
|
onCancel={handleCancel}
|
|
>
|
|
<Select
|
|
defaultFocusValue={defaultValue}
|
|
options={options}
|
|
onChange={handleSelect}
|
|
/>
|
|
</Dialog>
|
|
)
|
|
}
|