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
252 changes: 252 additions & 0 deletions src/components/DeleteSessionConfirm.render.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import { PassThrough } from 'node:stream'

import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import React from 'react'
import { stripVTControlCharacters as stripAnsi } from 'node:util'

import { createRoot } from '../ink.js'
import { DEFAULT_BINDINGS } from '../keybindings/defaultBindings.js'
import { KeybindingProvider } from '../keybindings/KeybindingContext.js'
import { parseBindings } from '../keybindings/parser.js'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
} from '../test/sharedMutationLock.js'
import type {
KeybindingContextName,
ParsedKeystroke,
} from '../keybindings/types.js'
import { getDefaultAppState } from '../state/AppStateStore.js'
import { DeleteSessionConfirm } from './DeleteSessionConfirm.js'

const sessionId = '550e8400-e29b-41d4-a716-446655440000'

function createTestStreams() {
let output = ''
const stdout = new PassThrough()
const stdin = new PassThrough() as PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
stdin.isTTY = true
stdin.setRawMode = () => {}
stdin.ref = () => {}
stdin.unref = () => {}
;(stdout as unknown as { columns: number }).columns = 120
stdout.on('data', chunk => {
output += chunk.toString()
})
return { stdout, stdin, getOutput: () => output }
}

async function waitFor(
predicate: () => boolean,
timeoutMs = 2000,
): Promise<void> {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (predicate()) return
await Bun.sleep(10)
}
throw new Error('Timed out waiting for DeleteSessionConfirm render test')
}

function TestKeybindingProvider({
children,
}: {
children: React.ReactNode
}): React.ReactNode {
const bindings = React.useMemo(() => parseBindings(DEFAULT_BINDINGS), [])
const pendingChordRef = React.useRef<ParsedKeystroke[] | null>(null)
const [pendingChord, setPendingChordState] = React.useState<
ParsedKeystroke[] | null
>(null)
const activeContextsRef = React.useRef<Set<KeybindingContextName>>(new Set())
const handlerRegistryRef = React.useRef(
new Map<
string,
Set<{
action: string
context: KeybindingContextName
handler: () => void
}>
>(),
)
const setPendingChord = React.useCallback(
(pending: ParsedKeystroke[] | null) => {
pendingChordRef.current = pending
setPendingChordState(pending)
},
[],
)
const registerActiveContext = React.useCallback(
(context: KeybindingContextName) => {
activeContextsRef.current.add(context)
},
[],
)
const unregisterActiveContext = React.useCallback(
(context: KeybindingContextName) => {
activeContextsRef.current.delete(context)
},
[],
)
return (
<KeybindingProvider
bindings={bindings}
pendingChordRef={pendingChordRef}
pendingChord={pendingChord}
setPendingChord={setPendingChord}
activeContexts={activeContextsRef.current}
registerActiveContext={registerActiveContext}
unregisterActiveContext={unregisterActiveContext}
handlerRegistryRef={handlerRegistryRef}
>
{children}
</KeybindingProvider>
)
}

async function renderConfirm(onConfirm: () => void, onCancel: () => void) {
const { stdout, stdin, getOutput } = createTestStreams()
const { AppStateProvider } = await import('../state/AppState.js')
const root = await createRoot({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
root.render(
<AppStateProvider initialState={getDefaultAppState() as never}>
<TestKeybindingProvider>
<DeleteSessionConfirm
sessionTitle="Fix the login bug"
sessionId={sessionId}
onConfirm={onConfirm}
onCancel={onCancel}
/>
</TestKeybindingProvider>
</AppStateProvider>,
)
return { stdout, stdin, getOutput, root }
}

beforeEach(async () => {
await acquireSharedMutationLock(
'components/DeleteSessionConfirm.render.test.tsx',
)
})

afterEach(() => {
try {
mock.restore()
} finally {
releaseSharedMutationLock()
}
})

describe('DeleteSessionConfirm (rendered)', () => {
test('shows title, short id, and irreversibility warning', async () => {
const { stdout, stdin, getOutput, root } = await renderConfirm(
mock(() => {}),
mock(() => {}),
)
try {
await waitFor(() =>
stripAnsi(getOutput()).includes('Delete session?'),
)
const output = stripAnsi(getOutput())
expect(output).toContain('Fix the login bug')
expect(output).toContain('550e8400')
expect(output).toContain('440000')
expect(output).toContain('cannot be undone')
} finally {
root.unmount()
stdin.end()
stdout.end()
}
})

test('Enter confirms without cancelling', async () => {
const onConfirm = mock(() => {})
const onCancel = mock(() => {})
const { stdout, stdin, getOutput, root } = await renderConfirm(
onConfirm,
onCancel,
)
try {
await waitFor(() =>
stripAnsi(getOutput()).includes('Delete session?'),
)
stdin.write('\r')
await waitFor(() => onConfirm.mock.calls.length > 0)
expect(onCancel).not.toHaveBeenCalled()
} finally {
root.unmount()
stdin.end()
stdout.end()
}
})

test('Escape cancels without confirming', async () => {
const onConfirm = mock(() => {})
const onCancel = mock(() => {})
const { stdout, stdin, getOutput, root } = await renderConfirm(
onConfirm,
onCancel,
)
try {
await waitFor(() =>
stripAnsi(getOutput()).includes('Delete session?'),
)
stdin.write('\x1b')
await waitFor(() => onCancel.mock.calls.length > 0)
expect(onConfirm).not.toHaveBeenCalled()
} finally {
root.unmount()
stdin.end()
stdout.end()
}
})

test('n cancels without confirming', async () => {
const onConfirm = mock(() => {})
const onCancel = mock(() => {})
const { stdout, stdin, getOutput, root } = await renderConfirm(
onConfirm,
onCancel,
)
try {
await waitFor(() =>
stripAnsi(getOutput()).includes('Delete session?'),
)
stdin.write('n')
await waitFor(() => onCancel.mock.calls.length > 0)
expect(onConfirm).not.toHaveBeenCalled()
} finally {
root.unmount()
stdin.end()
stdout.end()
}
})

test('guide advertises y and n bindings', async () => {
const { stdout, stdin, getOutput, root } = await renderConfirm(
mock(() => {}),
mock(() => {}),
)
try {
await waitFor(() =>
stripAnsi(getOutput()).includes('Delete session?'),
)
const output = stripAnsi(getOutput())
expect(output).toContain('Enter/y')
expect(output).toContain('Esc/n')
} finally {
root.unmount()
stdin.end()
stdout.end()
}
})
})
36 changes: 36 additions & 0 deletions src/components/DeleteSessionConfirm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from 'react';
import { Text } from '../ink.js';
import { useKeybinding } from '../keybindings/useKeybinding.js';
import { Byline } from './design-system/Byline.js';
import { Dialog } from './design-system/Dialog.js';
import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js';

type Props = {
sessionTitle: string;
sessionId: string;
onConfirm: () => void;
onCancel: () => void;
};

// Plain React (not react-compiler output): dialogs render rarely, and the
// memo-slot bookkeeping isn't worth maintaining here (see Dialog.tsx).
export function DeleteSessionConfirm({
sessionTitle,
sessionId,
onConfirm,
onCancel,
}: Props): React.ReactNode {
useKeybinding('confirm:yes', onConfirm, { context: 'Confirmation' });
const shortId =
sessionId.length > 14
? `${sessionId.slice(0, 8)}…${sessionId.slice(-6)}`
: sessionId;
return (
<Dialog title="Delete session?" color="error" onCancel={onCancel} inputGuide={exitState => exitState.pending ? <Text>Press {exitState.keyName} again to exit</Text> : <Byline><KeyboardShortcutHint shortcut="Enter/y" action="confirm" /><KeyboardShortcutHint shortcut="Esc/n" action="cancel" /></Byline>}>
<Text>
{sessionTitle} ({shortId})
</Text>
<Text color="error">This cannot be undone.</Text>
</Dialog>
);
}
33 changes: 30 additions & 3 deletions src/components/LogSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export type LogSelectorProps = {
forceWidth?: number;
onCancel?: () => void;
onSelect: (log: LogOption) => void;
onDeleteRequest?: (log: LogOption) => void;
isInputDisabled?: boolean;
onLogsChanged?: () => void;
onLoadMore?: (count: number) => void;
initialSearchQuery?: string;
Expand Down Expand Up @@ -239,6 +241,8 @@ export function LogSelector(t0: LogSelectorProps) {
forceWidth,
onCancel,
onSelect,
onDeleteRequest,
isInputDisabled: t1a,
onLogsChanged,
onLoadMore,
initialSearchQuery,
Expand All @@ -248,6 +252,7 @@ export function LogSelector(t0: LogSelectorProps) {
} = t0;
const maxHeight = t1 === undefined ? Infinity : t1;
const showAllProjects = t2 === undefined ? false : t2;
const isInputDisabled = t1a === undefined ? false : t1a;
const terminalSize = useTerminalSize();
const columns = forceWidth === undefined ? terminalSize.columns : forceWidth;
const exitState = useExitOnCtrlCDWithKeybindings(onCancel);
Expand Down Expand Up @@ -308,6 +313,18 @@ export function LogSelector(t0: LogSelectorProps) {
const [viewMode, setViewMode] = React.useState<ViewMode>("list");
const [previewLog, setPreviewLog] = React.useState<LogOption | null>(null);
const prevFocusedIdRef = React.useRef<string | null>(null);
// Fresh-per-keypress view of the delete props for the memoized input
// handler below (t53): reading via ref avoids adding new memo slots.
const deleteUiRef = React.useRef({
onDeleteRequest,
isInputDisabled
});
React.useEffect(() => {
deleteUiRef.current = {
onDeleteRequest,
isInputDisabled
};
});
const [selectedTagIndex, setSelectedTagIndex] = React.useState(0);
let t8: AgenticSearchState;
if ($[7] === Symbol.for("react.memo_cache_sentinel")) {
Expand Down Expand Up @@ -1120,6 +1137,9 @@ export function LogSelector(t0: LogSelectorProps) {
let t53;
if ($[131] !== agenticSearchState.status || $[132] !== branchFilterEnabled || $[133] !== focusedLog || $[134] !== handleAgenticSearch || $[135] !== hasMultipleWorktrees || $[136] !== hasTags || $[137] !== isAgenticSearchOptionFocused || $[138] !== onAgenticSearch || $[139] !== onToggleAllProjects || $[140] !== searchQuery || $[141] !== setSearchQuery || $[142] !== showAllProjects || $[143] !== showAllWorktrees || $[144] !== tagTabs || $[145] !== uniqueTags || $[146] !== viewMode) {
t53 = (input, key) => {
if (deleteUiRef.current.isInputDisabled) {
return;
}
if (viewMode === "preview") {
return;
}
Expand Down Expand Up @@ -1210,6 +1230,10 @@ export function LogSelector(t0: LogSelectorProps) {
messageCount: focusedLog.messageCount
});
} else {
if (key.delete && focusedLog) {
deleteUiRef.current.onDeleteRequest?.(focusedLog);
return;
}
if (focusedLog && keyIsNotCtrlOrMeta && input.length > 0 && !/^\s+$/.test(input)) {
setViewMode("search");
setSearchQuery(input);
Expand Down Expand Up @@ -1424,11 +1448,14 @@ export function LogSelector(t0: LogSelectorProps) {
} else {
t69 = $[201];
}
// isInputDisabled is read directly (not in the t70 dep list below):
// the parent passes fresh onSelect/onLogsChanged identities every
// render, so t70 already recomputes on the renders that flip the flag.
let t70;
if ($[202] !== agenticSearchState.status || $[203] !== branchFilterEnabled || $[204] !== columns || $[205] !== displayedLogs || $[206] !== expandedGroupSessionIds || $[207] !== flatOptions || $[208] !== focusedLog || $[209] !== focusedNode?.id || $[210] !== handleFlatOptionsSelectFocus || $[211] !== handleRenameSubmit || $[212] !== handleTreeSelectFocus || $[213] !== isAgenticSearchOptionFocused || $[214] !== onCancel || $[215] !== onSelect || $[216] !== renameCursorOffset || $[217] !== renameValue || $[218] !== treeNodes || $[219] !== viewMode || $[220] !== visibleCount) {
t70 = agenticSearchState.status === "searching" ? null : viewMode === "rename" && focusedLog ? <Box paddingLeft={2} flexDirection="column"><Text bold={true}>Rename session:</Text><Box paddingTop={1}><TextInput value={renameValue} onChange={setRenameValue} onSubmit={handleRenameSubmit} placeholder={getResumeLogDisplayTitle(focusedLog) || "Enter new session name"} columns={columns} cursorOffset={renameCursorOffset} onChangeCursorOffset={setRenameCursorOffset} showCursor={true} /></Box></Box> : isResumeWithRenameEnabled ? <TreeSelect nodes={treeNodes} onSelect={node_0 => {
onSelect(node_0.value.log);
}} onFocus={handleTreeSelectFocus} onCancel={onCancel} focusNodeId={focusedNode?.id} visibleOptionCount={visibleCount} layout="expanded" isDisabled={viewMode === "search" || isAgenticSearchOptionFocused} hideIndexes={false} isNodeExpanded={nodeId => {
}} onFocus={handleTreeSelectFocus} onCancel={onCancel} focusNodeId={focusedNode?.id} visibleOptionCount={visibleCount} layout="expanded" isDisabled={viewMode === "search" || isAgenticSearchOptionFocused || isInputDisabled} hideIndexes={false} isNodeExpanded={nodeId => {
if (viewMode === "search" || branchFilterEnabled) {
return true;
}
Expand All @@ -1455,7 +1482,7 @@ export function LogSelector(t0: LogSelectorProps) {
if (log_13) {
onSelect(log_13);
}
}} visibleOptionCount={visibleCount} onCancel={onCancel} onFocus={handleFlatOptionsSelectFocus} defaultFocusValue={focusedNode?.id.toString()} layout="expanded" isDisabled={viewMode === "search" || isAgenticSearchOptionFocused} onUpFromFirstItem={enterSearchMode} />;
}} visibleOptionCount={visibleCount} onCancel={onCancel} onFocus={handleFlatOptionsSelectFocus} defaultFocusValue={focusedNode?.id.toString()} layout="expanded" isDisabled={viewMode === "search" || isAgenticSearchOptionFocused || isInputDisabled} onUpFromFirstItem={enterSearchMode} />;
$[202] = agenticSearchState.status;
$[203] = branchFilterEnabled;
$[204] = columns;
Expand All @@ -1481,7 +1508,7 @@ export function LogSelector(t0: LogSelectorProps) {
}
let t71;
if ($[222] !== agenticSearchState.status || $[223] !== currentBranch || $[224] !== exitState.keyName || $[225] !== exitState.pending || $[226] !== getExpandCollapseHint || $[227] !== hasMultipleWorktrees || $[228] !== isAgenticSearchOptionFocused || $[229] !== isSearching || $[230] !== onToggleAllProjects || $[231] !== showAllProjects || $[232] !== showAllWorktrees || $[233] !== viewMode) {
t71 = <Box paddingLeft={2}>{exitState.pending ? <Text dimColor={true}>Press {exitState.keyName} again to exit</Text> : viewMode === "rename" ? <Text dimColor={true}><Byline><KeyboardShortcutHint shortcut="Enter" action="save" /><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" /></Byline></Text> : agenticSearchState.status === "searching" ? <Text dimColor={true}><Byline><Text>Searching with Claude…</Text><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" /></Byline></Text> : isAgenticSearchOptionFocused ? <Text dimColor={true}><Byline><KeyboardShortcutHint shortcut="Enter" action="search" /><KeyboardShortcutHint shortcut={"\u2193"} action="skip" /><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" /></Byline></Text> : viewMode === "search" ? <Text dimColor={true}><Byline><Text>{isSearching && false ? "Searching\u2026" : "Type to Search"}</Text><KeyboardShortcutHint shortcut="Enter" action="select" /><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="clear" /></Byline></Text> : <Text dimColor={true}><Byline>{onToggleAllProjects && <KeyboardShortcutHint shortcut="Ctrl+A" action={`show ${showAllProjects ? "current dir" : "all projects"}`} />}{currentBranch && <KeyboardShortcutHint shortcut="Ctrl+B" action="toggle branch" />}{hasMultipleWorktrees && <KeyboardShortcutHint shortcut="Ctrl+W" action={`show ${showAllWorktrees ? "current worktree" : "all worktrees"}`} />}<KeyboardShortcutHint shortcut="Ctrl+V" action="preview" /><KeyboardShortcutHint shortcut="Ctrl+R" action="rename" /><Text>Type to search</Text><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />{getExpandCollapseHint() && <Text>{getExpandCollapseHint()}</Text>}</Byline></Text>}</Box>;
t71 = <Box paddingLeft={2}>{exitState.pending ? <Text dimColor={true}>Press {exitState.keyName} again to exit</Text> : viewMode === "rename" ? <Text dimColor={true}><Byline><KeyboardShortcutHint shortcut="Enter" action="save" /><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" /></Byline></Text> : agenticSearchState.status === "searching" ? <Text dimColor={true}><Byline><Text>Searching with Claude…</Text><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" /></Byline></Text> : isAgenticSearchOptionFocused ? <Text dimColor={true}><Byline><KeyboardShortcutHint shortcut="Enter" action="search" /><KeyboardShortcutHint shortcut={"\u2193"} action="skip" /><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" /></Byline></Text> : viewMode === "search" ? <Text dimColor={true}><Byline><Text>{isSearching && false ? "Searching\u2026" : "Type to Search"}</Text><KeyboardShortcutHint shortcut="Enter" action="select" /><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="clear" /></Byline></Text> : <Text dimColor={true}><Byline>{onToggleAllProjects && <KeyboardShortcutHint shortcut="Ctrl+A" action={`show ${showAllProjects ? "current dir" : "all projects"}`} />}{currentBranch && <KeyboardShortcutHint shortcut="Ctrl+B" action="toggle branch" />}{hasMultipleWorktrees && <KeyboardShortcutHint shortcut="Ctrl+W" action={`show ${showAllWorktrees ? "current worktree" : "all worktrees"}`} />}<KeyboardShortcutHint shortcut="Ctrl+V" action="preview" /><KeyboardShortcutHint shortcut="Ctrl+R" action="rename" /><KeyboardShortcutHint shortcut="Del" action="delete" /><Text>Type to search</Text><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />{getExpandCollapseHint() && <Text>{getExpandCollapseHint()}</Text>}</Byline></Text>}</Box>;
$[222] = agenticSearchState.status;
$[223] = currentBranch;
$[224] = exitState.keyName;
Expand Down
Loading
Loading