claude-code/components/WorktreeExitDialog.tsx

231 lines
35 KiB
TypeScript
Raw Permalink Normal View History

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 } from '../ink.js';
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 { Dialog } from './design-system/Dialog.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>;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsInVzZUVmZmVjdCIsInVzZVN0YXRlIiwiQ29tbWFuZFJlc3VsdERpc3BsYXkiLCJsb2dFdmVudCIsImxvZ0ZvckRlYnVnZ2luZyIsIkJveCIsIlRleHQiLCJleGVjRmlsZU5vVGhyb3ciLCJnZXRQbGFuc0RpcmVjdG9yeSIsInNldEN3ZCIsImNsZWFudXBXb3JrdHJlZSIsImdldEN1cnJlbnRXb3JrdHJlZVNlc3Npb24iLCJrZWVwV29ya3RyZWUiLCJraWxsVG11eFNlc3Npb24iLCJTZWxlY3QiLCJEaWFsb2ciLCJTcGlubmVyIiwicmVjb3JkV29ya3RyZWVFeGl0IiwicmVxdWlyZSIsInNhdmVXb3JrdHJlZVN0YXRlIiwiUHJvcHMiLCJvbkRvbmUiLCJyZXN1bHQiLCJvcHRpb25zIiwiZGlzcGxheSIsIm9uQ2FuY2VsIiwiV29ya3RyZWVFeGl0RGlhbG9nIiwiUmVhY3ROb2RlIiwic3RhdHVzIiwic2V0U3RhdHVzIiwiY2hhbmdlcyIsInNldENoYW5nZXMiLCJjb21taXRDb3VudCIsInNldENvbW1pdENvdW50IiwicmVzdWx0TWVzc2FnZSIsInNldFJlc3VsdE1lc3NhZ2UiLCJ3b3JrdHJlZVNlc3Npb24iLCJsb2FkQ2hhbmdlcyIsImNoYW5nZUxpbmVzIiwiZ2l0U3RhdHVzIiwic3Rkb3V0Iiwic3BsaXQiLCJmaWx0ZXIiLCJfIiwidHJpbSIsImNvbW1pdHNTdHIiLCJvcmlnaW5hbEhlYWRDb21taXQiLCJjb3VudCIsInBhcnNlSW50IiwibGVuZ3RoIiwidGhlbiIsInByb2Nlc3MiLCJjaGRpciIsIm9yaWdpbmFsQ3dkIiwiY2FjaGUiLCJjbGVhciIsImNhdGNoIiwiZXJyb3IiLCJsZXZlbCIsImhhbmRsZVNlbGVjdCIsInZhbHVlIiwiaGFzVG11eCIsIkJvb2xlYW4iLCJ0bXV4U2Vzc2lvbk5hbWUiLCJjb21taXRzIiwiY2hhbmdlZF9maWxlcyIsIndvcmt0cmVlUGF0aCIsIndvcmt0cmVlQnJhbmNoIiwidG11eE5vdGUiLCJicmFuY2hOYW1lIiwiaGFzVW5jb21taXR0ZWQiLCJoYXNDb21taXRzIiwic3VidGl0bGUiLCJoYW5kbGVDYW5jZWwiLCJyZW1vdmVEZXNjcmlwdGlvbiIsImhhc1RtdXhTZXNzaW9uIiwibGFiZWwiLCJkZXNjcmlwdGlvbiIsImRlZmF1bHRWYWx1ZSJdLCJzb3VyY2VzIjpbIldvcmt0cmVlRXhpdERpYWxvZy50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0LCB7IHVzZUVmZmVjdCwgdXNlU3RhdGUgfSBmcm9tICdyZWFjdCdcbmltcG9ydCB0eXBlIHsgQ29tbWFuZFJlc3VsdERpc3BsYXkgfSBmcm9tICdzcmMvY29tbWFuZHMuanMnXG5pbXBvcnQgeyBsb2dFdmVudCB9IGZyb20gJ3NyYy9zZXJ2aWNlcy9hbmFseXRpY3MvaW5kZXguanMnXG5pbXBvcnQgeyBsb2dGb3JEZWJ1Z2dpbmcgfSBmcm9tICdzcmMvdXRpbHMvZGVidWcuanMnXG5pbXBvcnQgeyBCb3gsIFRleHQgfSBmcm9tICcuLi9pbmsuanMnXG5pbXBvcnQgeyBleGVjRmlsZU5vVGhyb3cgfSBmcm9tICcuLi91dGlscy9leGVjRmlsZU5vVGhyb3cuanMnXG5pbXBvcnQgeyBnZXRQbGFuc0RpcmVjdG9yeSB9IGZyb20gJy4uL3V0aWxzL3BsYW5zLmpzJ1xuaW1wb3J0IHsgc2V0Q3dkIH0gZnJvbSAnLi4vdXRpbHMvU2hlbGwuanMnXG5pbXBvcnQge1xuICBjbGVhbnVwV29ya3RyZWUsXG4gIGdldEN1cnJlbnRXb3JrdHJlZVNlc3Npb24sXG4gIGtlZXBXb3JrdHJlZSxcbiAga2lsbFRtdXhTZXNzaW9uLFxufSBmcm9tICcuLi91dGlscy93b3JrdHJlZS5qcydcbmltcG9ydCB7IFNlbGVjdCB9IGZyb20gJy4vQ3VzdG9tU2VsZWN0L3NlbGVjdC5qcydcbmltcG9ydCB7IERpYWxvZyB9IGZyb20gJy4vZGVzaWduLXN5c3RlbS9EaWFsb2cuanMnXG5pbXBvcnQgeyBTcGlubmVyIH0gZnJvbSAnLi9TcGlubmVyLmpzJ1xuXG4vLyBJbmxpbmUgcmVxdWlyZSBicmVha3MgdGhlIGN5Y2xlIHRoaXMgZmlsZSB3b3VsZCBvdGhlcndpc2UgY2xvc2U6XG4vLyBzZXNzaW9uU3RvcmFnZSDihpIgY29tbWFuZHMg4oaSIGV4aXQg4oaSIEV4aXRGbG93IOKGkiBoZXJlLiBBbGwgY2FsbCBzaXRlc1xuLy8gYXJlIGluc2lkZSBjYWxsYmFja3MsIHNvIHRoZSBsYXp5IHJlcXVpcmUgbmV2ZXIgc2VlcyBhbiB1bmRlZmluZWQgaW1wb3J0LlxuZnVuY3Rpb24gcmVjb3JkV29ya3RyZWVFeGl0KCk6IHZvaWQge1xuICAvKiBlc2xpbnQtZGlzYWJsZSBAdHlwZXNjcmlwdC1lc2xpbnQvbm8tcmVxdWlyZS1pbXBvcnRzICovXG4gIDsoXG4gICAgcmVxdWlyZSgnLi4vdXRpbHMvc2Vzc2lvblN0b3JhZ2UuanMnKSBhcyB0eXBlb2YgaW1wb3J0KCcuLi91dGlscy9zZXNzaW9uU3RvcmFnZS5qcycpXG4gICkuc2F2ZVdvcmt0cmVlU3RhdGUobnVsbClcbiAgLyogZXNsaW50LWVuYWJsZSBAdHlwZXNjcmlwdC1lc2xpbnQvbm8tcmVxdWlyZS1pbXBvcnRzICovXG59XG5cbnR5cGUgUHJvcHMgPSB7XG4gIG9uRG9uZTogKFxuICAgIHJlc3VsdD86IHN0cmluZyxcbiAgICBvcHRpb25zPzogeyBkaXNwbGF5PzogQ29tbWFuZFJlc3VsdERpc3BsYXkgfSxcbiAgKSA9PiB2b2lkXG4gIG9uQ2FuY2VsPzogKCkgPT4gdm9pZFxufVxuXG5leHBvcnQgZnVuY3Rpb24gV29ya3RyZWVFeGl0RGlhbG9nKHtcbiAgb25Eb25lLFxuICBvbkNhbmNlbCxcbn06IFByb3BzKTogUmVhY3QuUmVhY3ROb2RlIHtcbiAgY29uc3QgW3N0YXR1cywgc2V0U3RhdHVzXSA9IHVzZVN0YXRlPFxuICAgICdsb2FkaW5nJyB8ICdhc2tpbmcnIHwgJ2tlZXBpbmcnIHwgJ3JlbW92aW5nJyB8ICdkb25lJ1xuICA+KCdsb2FkaW5nJylcbiAgY29uc3QgW2NoYW5nZXMsIHNldENoYW5nZXNdID0gdXNlU3RhdGU8c3RyaW5nW10+KFtdKVxuICBjb25zdCBbY29tbWl0Q291bnQsIHNldENvbW1pdENvdW50XSA9IHVzZVN0YXRlPG51bWJlcj4oMClcbiAgY29uc3QgW3Jlc3VsdE1lc3NhZ2UsIHNldFJlc3VsdE1lc3NhZ2VdID0gdXNlU3RhdGU8c3RyaW5nIHwgdW5kZWZpbmVkPigpXG4gIGNvbnN0IHdvcmt0cmVlU2Vzc2lvbiA9IGdldEN1cnJlbnRXb3JrdHJlZVNlc3Npb24oKVxuXG4gIHVzZUVmZmVjdCgoKSA9PiB7XG4gICAgYXN5bmMgZnVuY3Rpb24gbG9hZENoYW5nZXMoKSB7XG4gICAgICBsZXQgY2hhbmdlTGluZXM6IHN0cmluZ1tdID0gW11cbiAgICAgIGNvbnN0IGdpdFN