Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 72 additions & 8 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Dashboard from './Dashboard.js';
import Session from './Session.js';
import NewWorktree from './NewWorktree.js';
import DeleteWorktree from './DeleteWorktree.js';
import DeleteConfirmation from './DeleteConfirmation.js';
import MergeWorktree from './MergeWorktree.js';
import Configuration from './Configuration.js';
import PresetSelector from './PresetSelector.js';
Expand Down Expand Up @@ -36,7 +37,10 @@ import {ConfigScope} from '../types/index.js';
import {ENV_VARS} from '../constants/env.js';
import {MULTI_PROJECT_ERRORS} from '../constants/error.js';
import {projectManager} from '../services/projectManager.js';
import {generateWorktreeDirectory} from '../utils/worktreeUtils.js';
import {
generateWorktreeDirectory,
isDeletableWorktree,
} from '../utils/worktreeUtils.js';

type View =
| 'menu'
Expand All @@ -48,6 +52,7 @@ type View =
| 'creating-session'
| 'creating-session-preset'
| 'delete-worktree'
| 'confirm-delete-worktree'
| 'deleting-worktree'
| 'merge-worktree'
| 'configuration'
Expand Down Expand Up @@ -96,9 +101,17 @@ const App: React.FC<AppProps> = ({
name?: string;
} | null>(null);
const [sessionActionsTarget, setSessionActionsTarget] = useState<{
session: ISession;
worktreePath: string;
session?: ISession;
// Present only when the actions menu was opened from a menu row, which is
// the only entry point that knows the worktree; the Dashboard opens it
// from a session alone and therefore offers no worktree deletion.
worktree?: Worktree;
} | null>(null);
// Worktree awaiting confirmation of the per-row delete action
const [worktreeToDelete, setWorktreeToDelete] = useState<Worktree | null>(
null,
);
const [selectedProject, setSelectedProject] = useState<GitProject | null>(
null,
); // Store selected project in multi-project mode
Expand Down Expand Up @@ -498,8 +511,9 @@ const App: React.FC<AppProps> = ({
return;
case 'sessionActions':
setSessionActionsTarget({
worktreePath: action.worktree.path,
session: action.session,
worktreePath: action.worktreePath,
worktree: action.worktree,
});
navigateWithClear('session-actions');
return;
Expand Down Expand Up @@ -798,6 +812,11 @@ const App: React.FC<AppProps> = ({
const handleDeleteWorktrees = async (
worktreePaths: string[],
deleteBranch: boolean,
options?: {
// Where to send the user when a deletion fails. Defaults to the
// multi-select delete screen, which is where this flow starts.
onError?: () => void;
},
) => {
// Set loading context before showing loading view
setLoadingContext({deleteBranch});
Expand Down Expand Up @@ -846,7 +865,11 @@ const App: React.FC<AppProps> = ({
handleReturnToMenu();
} else {
// Show error
setView('delete-worktree');
if (options?.onError) {
options.onError();
} else {
setView('delete-worktree');
}
}
};

Expand Down Expand Up @@ -1092,10 +1115,18 @@ const App: React.FC<AppProps> = ({
}

if (view === 'session-actions' && sessionActionsTarget) {
const {session: targetSession, worktreePath} = sessionActionsTarget;
const label = targetSession.sessionName
? targetSession.sessionName
: `Session #${targetSession.sessionNumber}`;
const {
session: targetSession,
worktreePath,
worktree: targetWorktree,
} = sessionActionsTarget;
// A worktree row without a session has no session name to show; the
// worktree path is rendered on its own line by SessionActions.
const label = !targetSession
? undefined
: targetSession.sessionName
? targetSession.sessionName
: `Session #${targetSession.sessionNumber}`;

const handleSessionAction = async (action: SessionActionType) => {
setSessionActionsTarget(null);
Expand All @@ -1112,23 +1143,34 @@ const App: React.FC<AppProps> = ({
);
return;
case 'rename':
if (!targetSession) return;
setRenameTarget({
id: targetSession.id,
name: targetSession.sessionName,
});
navigateWithClear('rename-session');
return;
case 'kill':
if (!targetSession) return;
sessionManager.destroySession(targetSession.id);
handleReturnToMenu();
return;
case 'deleteWorktree':
if (!targetWorktree) return;
setWorktreeToDelete(targetWorktree);
navigateWithClear('confirm-delete-worktree');
return;
}
};

return (
<SessionActions
sessionLabel={label}
worktreePath={worktreePath}
hasSession={!!targetSession}
canDeleteWorktree={
!!targetWorktree && isDeletableWorktree(targetWorktree)
}
onSelect={handleSessionAction}
onCancel={() => {
setSessionActionsTarget(null);
Expand All @@ -1138,6 +1180,28 @@ const App: React.FC<AppProps> = ({
);
}

if (view === 'confirm-delete-worktree' && worktreeToDelete) {
const target = worktreeToDelete;

return (
<DeleteConfirmation
worktrees={[target]}
onConfirm={deleteBranch => {
setWorktreeToDelete(null);
void handleDeleteWorktrees([target.path], deleteBranch, {
// The multi-select delete screen was never opened in this flow,
// so surface the failure on the menu instead.
onError: handleReturnToMenu,
});
}}
onCancel={() => {
setWorktreeToDelete(null);
handleReturnToMenu();
}}
/>
);
}

if (view === 'preset-selector') {
return (
<PresetSelector
Expand Down
18 changes: 4 additions & 14 deletions src/components/DeleteWorktree.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import React, {useState, useEffect} from 'react';
import path from 'path';
import {Box, Text, useInput} from 'ink';
import SelectInput from 'ink-select-input';
import {Effect} from 'effect';
Expand All @@ -11,6 +10,7 @@ import {useSearchMode} from '../hooks/useSearchMode.js';
import {useDynamicLimit} from '../hooks/useDynamicLimit.js';
import {filterWorktreesByQuery} from '../utils/filterByQuery.js';
import SearchableList from './SearchableList.js';
import {isDeletableWorktree} from '../utils/worktreeUtils.js';

interface DeleteWorktreeProps {
projectPath?: string;
Expand Down Expand Up @@ -59,19 +59,9 @@ const DeleteWorktree: React.FC<DeleteWorktreeProps> = ({
);

if (!cancelled) {
// Filter out main worktree and current working directory worktree
const resolvedCwd = path.resolve(process.cwd());
const deletableWorktrees = allWorktrees.filter(wt => {
if (wt.isMainWorktree) return false;
const resolvedPath = path.resolve(wt.path);
if (
resolvedCwd === resolvedPath ||
resolvedCwd.startsWith(resolvedPath + path.sep)
) {
return false;
}
return true;
});
const deletableWorktrees = allWorktrees.filter(wt =>
isDeletableWorktree(wt),
);
setWorktrees(deletableWorktrees);
setIsLoading(false);
}
Expand Down
56 changes: 55 additions & 1 deletion src/components/Menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -473,8 +473,62 @@ describe('Menu component Effect-based error handling', () => {

expect(onMenuAction).toHaveBeenCalledWith({
type: 'sessionActions',
worktree: cachedWorktree,
session: cachedSession,
worktreePath: '/test/cached',
});
});

it('should open the actions menu with Space on a worktree row that has no session', async () => {
const {Effect} = await import('effect');
const sessionlessWorktree = {
path: '/test/no-session',
branch: 'feature/no-session',
isMainWorktree: false,
hasSession: false,
};

vi.spyOn(sessionManager, 'getAllSessions').mockReturnValue([]);
vi.spyOn(worktreeService, 'getWorktreesEffect').mockReturnValue(
Effect.succeed([sessionlessWorktree]),
);
vi.spyOn(worktreeService, 'getDefaultBranchEffect').mockReturnValue(
Effect.succeed('main'),
);

const onMenuAction = vi.fn();
vi.mocked(useInput).mockClear();

render(
<Menu
sessionManager={sessionManager}
worktreeService={worktreeService}
initialSnapshot={{
worktrees: [sessionlessWorktree],
defaultBranch: 'main',
}}
onMenuAction={onMenuAction}
version="test"
/>,
);

await new Promise(resolve => setTimeout(resolve, 0));

// Menu's hotkey handler bails out when raw mode is unavailable.
const origSetRawMode = process.stdin.setRawMode;
process.stdin.setRawMode = vi.fn() as never;
try {
const calls = vi.mocked(useInput).mock.calls;
const handler = calls[calls.length - 1]?.[0];
expect(handler).toBeDefined();
handler!(' ', makeKey() as never);
} finally {
process.stdin.setRawMode = origSetRawMode;
}

expect(onMenuAction).toHaveBeenCalledWith({
type: 'sessionActions',
worktree: sessionlessWorktree,
session: undefined,
});
});
});
Expand Down
50 changes: 28 additions & 22 deletions src/components/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,15 @@ const Menu: React.FC<MenuProps> = ({
const worktrees = useGitStatus(baseWorktrees, defaultBranch);
// Seed from the in-memory session list so the cached snapshot renders with its
// sessions attached. Waiting for the async git load would leave every row
// session-less for that window, disabling the Space session-actions shortcut.
// session-less for that window, hiding the session entries of the Space
// actions menu.
const [sessions, setSessions] = useState<Session[]>(() =>
sessionManager.getAllSessions(),
);
const [items, setItems] = useState<MenuItem[]>([]);
const [recentProjects, setRecentProjects] = useState<RecentProject[]>([]);
const [highlightedWorktreePath, setHighlightedWorktreePath] = useState<
string | null
>(null);
const [highlightedWorktree, setHighlightedWorktree] =
useState<Worktree | null>(null);
const [highlightedSession, setHighlightedSession] = useState<
Session | undefined
>(undefined);
Expand Down Expand Up @@ -420,20 +420,23 @@ const Menu: React.FC<MenuProps> = ({
}
setItems(menuItems);

// Ensure highlighted worktree path is valid for hotkey support
setHighlightedWorktreePath(prev => {
if (
prev &&
menuItems.some(
item => item.type === 'worktree' && item.worktree.path === prev,
)
) {
return prev;
// Ensure highlighted worktree is valid for hotkey support
setHighlightedWorktree(prev => {
const stillListed = prev
? menuItems.find(
item =>
item.type === 'worktree' && item.worktree.path === prev.path,
)
: undefined;
if (stillListed && stillListed.type === 'worktree') {
// Re-read the item so the highlighted worktree keeps up with
// refreshed git status instead of pinning the stale object.
return stillListed.worktree;
}
const first = menuItems.find(item => item.type === 'worktree');
if (first && first.type === 'worktree') {
setHighlightedSession(first.session);
return first.worktree.path;
return first.worktree;
}
setHighlightedSession(undefined);
return null;
Expand Down Expand Up @@ -521,18 +524,21 @@ const Menu: React.FC<MenuProps> = ({
switch (keyPressed) {
case 'a':
// Toggle auto-approval for the currently highlighted worktree
if (configReader.isAutoApprovalEnabled() && highlightedWorktreePath) {
sessionManager.toggleAutoApprovalForWorktree(highlightedWorktreePath);
if (configReader.isAutoApprovalEnabled() && highlightedWorktree) {
sessionManager.toggleAutoApprovalForWorktree(
highlightedWorktree.path,
);
setAutoApprovalToggleCounter(c => c + 1);
}
break;
case ' ':
// Open session actions for highlighted session
if (highlightedSession && highlightedWorktreePath) {
// Open the row actions for the highlighted worktree. Rows without a
// session open the same menu with only the worktree-level entries.
if (highlightedWorktree) {
onMenuAction({
type: 'sessionActions',
worktree: highlightedWorktree,
session: highlightedSession,
worktreePath: highlightedWorktreePath,
});
}
break;
Expand Down Expand Up @@ -673,7 +679,7 @@ const Menu: React.FC<MenuProps> = ({
const item = items.find(i => i.value === raw?.value);
if (!item) return;
if (item.type === 'worktree') {
setHighlightedWorktreePath(item.worktree.path);
setHighlightedWorktree(item.worktree);
setHighlightedSession(item.session);
}
}}
Expand Down Expand Up @@ -712,12 +718,12 @@ const Menu: React.FC<MenuProps> = ({
{isSearchMode
? 'Search Mode: Type to filter, Enter to exit search, ESC to exit search'
: searchQuery
? `Controls: 鈫戔啌 Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select Tab-State Filter Space-Session actions (session rows only) N-New M-Merge D-Delete ${
? `Controls: 鈫戔啌 Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select Tab-State Filter Space-Worktree actions N-New M-Merge D-Delete ${
configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''
}${
multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'
} ${projectName ? 'B-Back' : 'Q-Quit'}`
: `Controls: 鈫戔啌 Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search Tab-State Filter Space-Session actions (session rows only) N-New M-Merge D-Delete ${
: `Controls: 鈫戔啌 Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search Tab-State Filter Space-Worktree actions N-New M-Merge D-Delete ${
configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''
}${
multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'
Expand Down
Loading
Loading