ccmanager 4.1.24 → 4.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/App.js +38 -1
- package/dist/components/App.test.js +40 -0
- package/dist/components/Menu.d.ts +8 -1
- package/dist/components/Menu.js +40 -14
- package/dist/components/Menu.test.js +70 -0
- package/dist/components/Session.js +8 -2
- package/dist/hooks/useGitStatus.d.ts +2 -0
- package/dist/hooks/useGitStatus.js +55 -2
- package/dist/hooks/useGitStatus.test.js +25 -1
- package/dist/services/sessionManager.d.ts +0 -6
- package/dist/services/sessionManager.js +0 -60
- package/dist/services/sessionManager.test.js +3 -49
- package/package.json +6 -6
package/dist/components/App.js
CHANGED
|
@@ -32,6 +32,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
32
32
|
const [error, setError] = useState(null);
|
|
33
33
|
const [worktreeHookError, setWorktreeHookError] = useState(null);
|
|
34
34
|
const [menuKey, setMenuKey] = useState(0); // Force menu refresh
|
|
35
|
+
const [menuSnapshots, setMenuSnapshots] = useState({});
|
|
35
36
|
const [selectedWorktree, setSelectedWorktree] = useState(null); // Store selected worktree for preset selection
|
|
36
37
|
const [renameTarget, setRenameTarget] = useState(null);
|
|
37
38
|
const [sessionActionsTarget, setSessionActionsTarget] = useState(null);
|
|
@@ -45,6 +46,25 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
45
46
|
// State for streaming devcontainer up logs
|
|
46
47
|
const [devcontainerLogs, setDevcontainerLogs] = useState([]);
|
|
47
48
|
const [canReturnFromHookError, setCanReturnFromHookError] = useState(false);
|
|
49
|
+
const menuSnapshotKey = selectedProject?.path ?? process.cwd();
|
|
50
|
+
const handleMenuSnapshotChange = useCallback((snapshot) => {
|
|
51
|
+
setMenuSnapshots(previous => ({
|
|
52
|
+
...previous,
|
|
53
|
+
[menuSnapshotKey]: snapshot,
|
|
54
|
+
}));
|
|
55
|
+
}, [menuSnapshotKey]);
|
|
56
|
+
const updateMenuSnapshot = useCallback((update) => {
|
|
57
|
+
setMenuSnapshots(previous => {
|
|
58
|
+
const current = previous[menuSnapshotKey];
|
|
59
|
+
if (!current) {
|
|
60
|
+
return previous;
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
...previous,
|
|
64
|
+
[menuSnapshotKey]: update(current),
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
}, [menuSnapshotKey]);
|
|
48
68
|
useEffect(() => {
|
|
49
69
|
if (view !== 'worktree-hook-error') {
|
|
50
70
|
setCanReturnFromHookError(false);
|
|
@@ -219,6 +239,18 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
219
239
|
// Helper function to handle worktree creation results
|
|
220
240
|
const handleWorktreeCreationResult = (result, creationData) => {
|
|
221
241
|
if (result.success) {
|
|
242
|
+
updateMenuSnapshot(snapshot => ({
|
|
243
|
+
...snapshot,
|
|
244
|
+
worktrees: [
|
|
245
|
+
...snapshot.worktrees.filter(worktree => worktree.path !== creationData.path),
|
|
246
|
+
{
|
|
247
|
+
path: creationData.path,
|
|
248
|
+
branch: creationData.branch,
|
|
249
|
+
isMainWorktree: false,
|
|
250
|
+
hasSession: false,
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
}));
|
|
222
254
|
if (result.warning) {
|
|
223
255
|
setError(null);
|
|
224
256
|
setWorktreeHookError(result.warning);
|
|
@@ -516,6 +548,11 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
516
548
|
}
|
|
517
549
|
}
|
|
518
550
|
if (!hasError) {
|
|
551
|
+
const deletedPaths = new Set(worktreePaths);
|
|
552
|
+
updateMenuSnapshot(snapshot => ({
|
|
553
|
+
...snapshot,
|
|
554
|
+
worktrees: snapshot.worktrees.filter(worktree => !deletedPaths.has(worktree.path)),
|
|
555
|
+
}));
|
|
519
556
|
// Success - return to menu
|
|
520
557
|
handleReturnToMenu();
|
|
521
558
|
}
|
|
@@ -581,7 +618,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
581
618
|
return (_jsx(Dashboard, { projectsDir: projectsDir, onSelectSession: handleSelectSessionFromDashboard, onSelectProject: handleSelectProject, onSessionAction: handleSessionActionFromDashboard, error: error, onDismissError: () => setError(null), version: version }));
|
|
582
619
|
}
|
|
583
620
|
if (view === 'menu') {
|
|
584
|
-
return (_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onMenuAction: handleMenuAction, onSelectRecentProject: handleSelectProject, error: error, onDismissError: () => setError(null), projectName: selectedProject?.name, multiProject: multiProject, version: version }, menuKey));
|
|
621
|
+
return (_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, initialSnapshot: menuSnapshots[menuSnapshotKey], onSnapshotChange: handleMenuSnapshotChange, onMenuAction: handleMenuAction, onSelectRecentProject: handleSelectProject, error: error, onDismissError: () => setError(null), projectName: selectedProject?.name, multiProject: multiProject, version: version }, menuKey));
|
|
585
622
|
}
|
|
586
623
|
if (view === 'session' && activeSession) {
|
|
587
624
|
return (_jsx(Box, { flexDirection: "column", children: _jsx(Session, { session: activeSession, sessionManager: sessionManager, onReturnToMenu: handleReturnToMenu }, activeSession.id) }));
|
|
@@ -169,6 +169,46 @@ describe('App component view state', () => {
|
|
|
169
169
|
});
|
|
170
170
|
});
|
|
171
171
|
describe('App component loading state machine', () => {
|
|
172
|
+
it('updates the cached worktree snapshot after creating and deleting worktrees', async () => {
|
|
173
|
+
const { unmount } = render(_jsx(App, { version: "test" }));
|
|
174
|
+
await waitForCondition(() => Boolean(menuProps));
|
|
175
|
+
menuProps.onSnapshotChange?.({
|
|
176
|
+
worktrees: [
|
|
177
|
+
{
|
|
178
|
+
path: '/tmp/existing',
|
|
179
|
+
branch: 'existing',
|
|
180
|
+
isMainWorktree: true,
|
|
181
|
+
hasSession: false,
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
defaultBranch: 'main',
|
|
185
|
+
});
|
|
186
|
+
await flush();
|
|
187
|
+
await Promise.resolve(menuProps.onMenuAction({ type: 'newWorktree' }));
|
|
188
|
+
await waitForCondition(() => Boolean(newWorktreeProps));
|
|
189
|
+
await Promise.resolve(newWorktreeProps.onComplete({
|
|
190
|
+
creationMode: 'manual',
|
|
191
|
+
path: '/tmp/created',
|
|
192
|
+
branch: 'created',
|
|
193
|
+
baseBranch: 'main',
|
|
194
|
+
copySessionData: false,
|
|
195
|
+
copyClaudeDirectory: false,
|
|
196
|
+
}));
|
|
197
|
+
await waitForCondition(() => Boolean(menuProps?.initialSnapshot?.worktrees.some(worktree => worktree.path === '/tmp/created')));
|
|
198
|
+
await Promise.resolve(menuProps.onMenuAction({ type: 'deleteWorktree' }));
|
|
199
|
+
await waitForCondition(() => Boolean(deleteWorktreeProps));
|
|
200
|
+
await Promise.resolve(deleteWorktreeProps.onComplete(['/tmp/created'], true));
|
|
201
|
+
await waitForCondition(() => !menuProps?.initialSnapshot?.worktrees.some(worktree => worktree.path === '/tmp/created'));
|
|
202
|
+
expect(menuProps?.initialSnapshot?.worktrees).toEqual([
|
|
203
|
+
{
|
|
204
|
+
path: '/tmp/existing',
|
|
205
|
+
branch: 'existing',
|
|
206
|
+
isMainWorktree: true,
|
|
207
|
+
hasSession: false,
|
|
208
|
+
},
|
|
209
|
+
]);
|
|
210
|
+
unmount();
|
|
211
|
+
});
|
|
172
212
|
it('displays copying message while creating a worktree with session data', async () => {
|
|
173
213
|
let resolveWorktree;
|
|
174
214
|
createWorktreeEffectMock.mockImplementation(() => Effect.tryPromise({
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { GitProject, MenuAction } from '../types/index.js';
|
|
2
|
+
import { Worktree, GitProject, MenuAction } from '../types/index.js';
|
|
3
3
|
import { WorktreeService } from '../services/worktreeService.js';
|
|
4
4
|
import { SessionManager } from '../services/sessionManager.js';
|
|
5
5
|
interface MenuProps {
|
|
6
6
|
sessionManager: SessionManager;
|
|
7
7
|
worktreeService: WorktreeService;
|
|
8
|
+
initialSnapshot?: MenuSnapshot;
|
|
9
|
+
onSnapshotChange?: (snapshot: MenuSnapshot) => void;
|
|
8
10
|
onMenuAction: (action: MenuAction) => void;
|
|
9
11
|
onSelectRecentProject?: (project: GitProject) => void;
|
|
10
12
|
error?: string | null;
|
|
@@ -13,5 +15,10 @@ interface MenuProps {
|
|
|
13
15
|
multiProject?: boolean;
|
|
14
16
|
version: string;
|
|
15
17
|
}
|
|
18
|
+
export interface MenuSnapshot {
|
|
19
|
+
worktrees: Worktree[];
|
|
20
|
+
defaultBranch: string | null;
|
|
21
|
+
loadError?: string | null;
|
|
22
|
+
}
|
|
16
23
|
declare const Menu: React.FC<MenuProps>;
|
|
17
24
|
export default Menu;
|
package/dist/components/Menu.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { useState, useEffect } from 'react';
|
|
2
|
+
import { useState, useEffect, useRef } from 'react';
|
|
3
3
|
import { Box, Text, useInput } from 'ink';
|
|
4
4
|
import SelectInput from 'ink-select-input';
|
|
5
5
|
import { Effect } from 'effect';
|
|
@@ -29,10 +29,15 @@ const createSeparatorWithText = (text, totalWidth = 35) => {
|
|
|
29
29
|
const formatGitError = (error) => {
|
|
30
30
|
return `Git command failed: ${error.command} (exit ${error.exitCode})\n${error.stderr}`;
|
|
31
31
|
};
|
|
32
|
-
const Menu = ({ sessionManager, worktreeService, onMenuAction, onSelectRecentProject, error, onDismissError, projectName, multiProject = false, version, }) => {
|
|
33
|
-
const [baseWorktrees, setBaseWorktrees] = useState([]);
|
|
34
|
-
const [defaultBranch, setDefaultBranch] = useState(null);
|
|
35
|
-
const [loadError, setLoadError] = useState(null);
|
|
32
|
+
const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChange, onMenuAction, onSelectRecentProject, error, onDismissError, projectName, multiProject = false, version, }) => {
|
|
33
|
+
const [baseWorktrees, setBaseWorktrees] = useState(() => initialSnapshot?.worktrees ?? []);
|
|
34
|
+
const [defaultBranch, setDefaultBranch] = useState(() => initialSnapshot?.defaultBranch ?? null);
|
|
35
|
+
const [loadError, setLoadError] = useState(() => initialSnapshot?.loadError ?? null);
|
|
36
|
+
const snapshotRef = useRef({
|
|
37
|
+
worktrees: initialSnapshot?.worktrees ?? [],
|
|
38
|
+
defaultBranch: initialSnapshot?.defaultBranch ?? null,
|
|
39
|
+
loadError: initialSnapshot?.loadError ?? null,
|
|
40
|
+
});
|
|
36
41
|
const worktrees = useGitStatus(baseWorktrees, defaultBranch);
|
|
37
42
|
const [sessions, setSessions] = useState([]);
|
|
38
43
|
const [items, setItems] = useState([]);
|
|
@@ -53,12 +58,12 @@ const Menu = ({ sessionManager, worktreeService, onMenuAction, onSelectRecentPro
|
|
|
53
58
|
const worktreeConfig = configReader.getWorktreeConfig();
|
|
54
59
|
useEffect(() => {
|
|
55
60
|
let cancelled = false;
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
const loadWorktreesAndBranch = Effect.
|
|
59
|
-
worktrees,
|
|
60
|
-
defaultBranch,
|
|
61
|
-
})
|
|
61
|
+
// These operations are independent. Run them concurrently so the initial
|
|
62
|
+
// menu load waits only for the slower command.
|
|
63
|
+
const loadWorktreesAndBranch = Effect.all({
|
|
64
|
+
worktrees: worktreeService.getWorktreesEffect(),
|
|
65
|
+
defaultBranch: worktreeService.getDefaultBranchEffect(),
|
|
66
|
+
}, { concurrency: 'unbounded' });
|
|
62
67
|
Effect.runPromise(Effect.match(loadWorktreesAndBranch, {
|
|
63
68
|
onFailure: (error) => ({
|
|
64
69
|
success: false,
|
|
@@ -83,17 +88,38 @@ const Menu = ({ sessionManager, worktreeService, onMenuAction, onSelectRecentPro
|
|
|
83
88
|
setBaseWorktrees(result.worktrees);
|
|
84
89
|
setDefaultBranch(result.defaultBranch);
|
|
85
90
|
setLoadError(null);
|
|
91
|
+
const snapshot = {
|
|
92
|
+
worktrees: result.worktrees,
|
|
93
|
+
defaultBranch: result.defaultBranch,
|
|
94
|
+
loadError: null,
|
|
95
|
+
};
|
|
96
|
+
snapshotRef.current = snapshot;
|
|
97
|
+
onSnapshotChange?.(snapshot);
|
|
86
98
|
}
|
|
87
99
|
else {
|
|
88
100
|
// Handle GitError with pattern matching
|
|
89
|
-
|
|
101
|
+
const loadError = formatGitError(result.error);
|
|
102
|
+
setLoadError(loadError);
|
|
103
|
+
const snapshot = {
|
|
104
|
+
...snapshotRef.current,
|
|
105
|
+
loadError,
|
|
106
|
+
};
|
|
107
|
+
snapshotRef.current = snapshot;
|
|
108
|
+
onSnapshotChange?.(snapshot);
|
|
90
109
|
}
|
|
91
110
|
}
|
|
92
111
|
})
|
|
93
112
|
.catch((err) => {
|
|
94
113
|
// This catch should not normally be reached with Effect.match
|
|
95
114
|
if (!cancelled) {
|
|
96
|
-
|
|
115
|
+
const loadError = String(err);
|
|
116
|
+
setLoadError(loadError);
|
|
117
|
+
const snapshot = {
|
|
118
|
+
...snapshotRef.current,
|
|
119
|
+
loadError,
|
|
120
|
+
};
|
|
121
|
+
snapshotRef.current = snapshot;
|
|
122
|
+
onSnapshotChange?.(snapshot);
|
|
97
123
|
}
|
|
98
124
|
});
|
|
99
125
|
// Load recent projects if in multi-project mode
|
|
@@ -118,7 +144,7 @@ const Menu = ({ sessionManager, worktreeService, onMenuAction, onSelectRecentPro
|
|
|
118
144
|
sessionManager.off('sessionDestroyed', handleSessionChange);
|
|
119
145
|
sessionManager.off('sessionStateChanged', handleSessionChange);
|
|
120
146
|
};
|
|
121
|
-
}, [sessionManager, worktreeService, multiProject]);
|
|
147
|
+
}, [sessionManager, worktreeService, multiProject, onSnapshotChange]);
|
|
122
148
|
useEffect(() => {
|
|
123
149
|
// Prepare worktree items and calculate layout
|
|
124
150
|
const items = prepareSessionItems(worktrees, sessions, {
|
|
@@ -183,6 +183,76 @@ describe('Menu component Effect-based error handling', () => {
|
|
|
183
183
|
expect(getWorktreesSpy).toHaveBeenCalled();
|
|
184
184
|
expect(getDefaultBranchSpy).toHaveBeenCalled();
|
|
185
185
|
});
|
|
186
|
+
it('should render a cached snapshot before its background refresh completes', async () => {
|
|
187
|
+
const { Effect } = await import('effect');
|
|
188
|
+
const cachedWorktree = {
|
|
189
|
+
path: '/test/cached',
|
|
190
|
+
branch: 'cached-branch',
|
|
191
|
+
isMainWorktree: true,
|
|
192
|
+
hasSession: false,
|
|
193
|
+
};
|
|
194
|
+
const refreshedWorktree = {
|
|
195
|
+
path: '/test/refreshed',
|
|
196
|
+
branch: 'refreshed-branch',
|
|
197
|
+
isMainWorktree: true,
|
|
198
|
+
hasSession: false,
|
|
199
|
+
};
|
|
200
|
+
let resolveWorktrees;
|
|
201
|
+
const worktreesPromise = new Promise(resolve => {
|
|
202
|
+
resolveWorktrees = resolve;
|
|
203
|
+
});
|
|
204
|
+
const onSnapshotChange = vi.fn();
|
|
205
|
+
vi.spyOn(worktreeService, 'getWorktreesEffect').mockReturnValue(Effect.promise(() => worktreesPromise));
|
|
206
|
+
const getDefaultBranchSpy = vi
|
|
207
|
+
.spyOn(worktreeService, 'getDefaultBranchEffect')
|
|
208
|
+
.mockReturnValue(Effect.succeed('main'));
|
|
209
|
+
const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, initialSnapshot: {
|
|
210
|
+
worktrees: [cachedWorktree],
|
|
211
|
+
defaultBranch: 'main',
|
|
212
|
+
}, onSnapshotChange: onSnapshotChange, onMenuAction: vi.fn(), version: "test" }));
|
|
213
|
+
// The cached rows are assembled in Menu's render effect, without waiting
|
|
214
|
+
// for the deferred Git refresh.
|
|
215
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
216
|
+
expect(lastFrame()).toContain('cached-branch');
|
|
217
|
+
expect(getDefaultBranchSpy).toHaveBeenCalled();
|
|
218
|
+
resolveWorktrees([refreshedWorktree]);
|
|
219
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
220
|
+
expect(lastFrame()).toContain('refreshed-branch');
|
|
221
|
+
expect(onSnapshotChange).toHaveBeenCalledWith({
|
|
222
|
+
worktrees: [refreshedWorktree],
|
|
223
|
+
defaultBranch: 'main',
|
|
224
|
+
loadError: null,
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
it('should retain cached worktrees when a background refresh fails', async () => {
|
|
228
|
+
const { Effect } = await import('effect');
|
|
229
|
+
const { GitError } = await import('../types/errors.js');
|
|
230
|
+
const cachedWorktree = {
|
|
231
|
+
path: '/test/cached',
|
|
232
|
+
branch: 'cached-branch',
|
|
233
|
+
isMainWorktree: true,
|
|
234
|
+
hasSession: false,
|
|
235
|
+
};
|
|
236
|
+
const onSnapshotChange = vi.fn();
|
|
237
|
+
vi.spyOn(worktreeService, 'getWorktreesEffect').mockReturnValue(Effect.fail(new GitError({
|
|
238
|
+
command: 'git worktree list --porcelain',
|
|
239
|
+
exitCode: 1,
|
|
240
|
+
stderr: 'temporary failure',
|
|
241
|
+
})));
|
|
242
|
+
vi.spyOn(worktreeService, 'getDefaultBranchEffect').mockReturnValue(Effect.succeed('main'));
|
|
243
|
+
const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, initialSnapshot: {
|
|
244
|
+
worktrees: [cachedWorktree],
|
|
245
|
+
defaultBranch: 'main',
|
|
246
|
+
}, onSnapshotChange: onSnapshotChange, onMenuAction: vi.fn(), version: "test" }));
|
|
247
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
248
|
+
expect(lastFrame()).toContain('cached-branch');
|
|
249
|
+
expect(lastFrame()).toContain('temporary failure');
|
|
250
|
+
expect(onSnapshotChange).toHaveBeenCalledWith({
|
|
251
|
+
worktrees: [cachedWorktree],
|
|
252
|
+
defaultBranch: 'main',
|
|
253
|
+
loadError: expect.stringContaining('temporary failure'),
|
|
254
|
+
});
|
|
255
|
+
});
|
|
186
256
|
});
|
|
187
257
|
describe('Menu component rendering', () => {
|
|
188
258
|
let sessionManager;
|
|
@@ -63,8 +63,14 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
63
63
|
session.process.write(data);
|
|
64
64
|
};
|
|
65
65
|
stdin.on('data', handleStdinData);
|
|
66
|
-
// Clear
|
|
67
|
-
|
|
66
|
+
// Clear the viewport and the host terminal's scrollback (\x1b[3J) when
|
|
67
|
+
// entering a session. The restore snapshot then rebuilds the whole
|
|
68
|
+
// canvas — scrollback included — from this session's headless buffer,
|
|
69
|
+
// so content left behind by the menu or other sessions can never
|
|
70
|
+
// appear when the user scrolls up. \x1b[2J runs first because some
|
|
71
|
+
// emulators (e.g. xterm.js-based ones) push the erased viewport into
|
|
72
|
+
// scrollback, which \x1b[3J must then discard.
|
|
73
|
+
stdout.write('\x1B[2J\x1B[3J\x1B[H');
|
|
68
74
|
// Restore the current terminal state from the headless xterm snapshot.
|
|
69
75
|
// The xterm serialize addon relies on auto-wrap (DECAWM) being enabled
|
|
70
76
|
// to render wrapped lines. It omits row separators for wrapped rows
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Worktree } from '../types/index.js';
|
|
2
|
+
/** Clear the module-level git-status cache. Intended for tests. */
|
|
3
|
+
export declare function clearGitStatusCache(): void;
|
|
2
4
|
/**
|
|
3
5
|
* Custom hook for polling git status and commit dates of worktrees with Effect-based execution
|
|
4
6
|
*
|
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
2
|
import { Effect, Exit, Cause, Option } from 'effect';
|
|
3
3
|
import { getGitStatusLimited, getLastCommitDateLimited, } from '../utils/gitStatus.js';
|
|
4
|
+
/**
|
|
5
|
+
* Module-level cache of the last fetched git status, keyed by worktree path.
|
|
6
|
+
*
|
|
7
|
+
* The menu is fully unmounted and remounted on every return to it (App renders
|
|
8
|
+
* `<Menu key={menuKey} />`, and the key changes on navigation), which would
|
|
9
|
+
* otherwise wipe this hook's state and force every worktree to flash
|
|
10
|
+
* "[fetching...]" and refetch from scratch each time. Seeding state from this
|
|
11
|
+
* cache shows each worktree's last known status instantly; background polling
|
|
12
|
+
* still refreshes it. Entries are intentionally not pruned so switching between
|
|
13
|
+
* projects keeps each project's cached status.
|
|
14
|
+
*/
|
|
15
|
+
const statusCache = new Map();
|
|
16
|
+
/** Clear the module-level git-status cache. Intended for tests. */
|
|
17
|
+
export function clearGitStatusCache() {
|
|
18
|
+
statusCache.clear();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Merge any cached status onto the given worktrees so a remount can show the
|
|
22
|
+
* last known status immediately instead of "[fetching...]". Returns the same
|
|
23
|
+
* array reference when nothing was cached, so React can skip a re-render.
|
|
24
|
+
*/
|
|
25
|
+
function hydrateFromCache(worktrees) {
|
|
26
|
+
let changed = false;
|
|
27
|
+
const next = worktrees.map(wt => {
|
|
28
|
+
const cached = statusCache.get(wt.path);
|
|
29
|
+
if (!cached) {
|
|
30
|
+
return wt;
|
|
31
|
+
}
|
|
32
|
+
changed = true;
|
|
33
|
+
return {
|
|
34
|
+
...wt,
|
|
35
|
+
gitStatus: cached.gitStatus,
|
|
36
|
+
gitStatusError: cached.gitStatusError,
|
|
37
|
+
lastCommitDate: cached.lastCommitDate,
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
return changed ? next : worktrees;
|
|
41
|
+
}
|
|
42
|
+
/** Record a worktree's latest status update into the module-level cache. */
|
|
43
|
+
function cacheStatusUpdate(path, update) {
|
|
44
|
+
const next = { ...statusCache.get(path) };
|
|
45
|
+
if ('gitStatus' in update) {
|
|
46
|
+
next.gitStatus = update.gitStatus;
|
|
47
|
+
}
|
|
48
|
+
if ('gitStatusError' in update) {
|
|
49
|
+
next.gitStatusError = update.gitStatusError;
|
|
50
|
+
}
|
|
51
|
+
if ('lastCommitDate' in update) {
|
|
52
|
+
next.lastCommitDate = update.lastCommitDate;
|
|
53
|
+
}
|
|
54
|
+
statusCache.set(path, next);
|
|
55
|
+
}
|
|
4
56
|
/**
|
|
5
57
|
* Custom hook for polling git status and commit dates of worktrees with Effect-based execution
|
|
6
58
|
*
|
|
@@ -20,7 +72,7 @@ import { getGitStatusLimited, getLastCommitDateLimited, } from '../utils/gitStat
|
|
|
20
72
|
* @returns Array of worktrees with updated gitStatus, gitStatusError, and lastCommitDate fields
|
|
21
73
|
*/
|
|
22
74
|
export function useGitStatus(worktrees, defaultBranch, updateInterval = 5000) {
|
|
23
|
-
const [worktreesWithStatus, setWorktreesWithStatus] = useState(worktrees);
|
|
75
|
+
const [worktreesWithStatus, setWorktreesWithStatus] = useState(() => hydrateFromCache(worktrees));
|
|
24
76
|
useEffect(() => {
|
|
25
77
|
if (!defaultBranch) {
|
|
26
78
|
return;
|
|
@@ -66,7 +118,7 @@ export function useGitStatus(worktrees, defaultBranch, updateInterval = 5000) {
|
|
|
66
118
|
}, updateInterval);
|
|
67
119
|
}
|
|
68
120
|
};
|
|
69
|
-
setWorktreesWithStatus(worktrees);
|
|
121
|
+
setWorktreesWithStatus(hydrateFromCache(worktrees));
|
|
70
122
|
runCycle().catch(() => {
|
|
71
123
|
// Ignore errors - the fetch failed or was aborted
|
|
72
124
|
});
|
|
@@ -97,6 +149,7 @@ function applyStatusResults(results, setWorktreesWithStatus) {
|
|
|
97
149
|
const update = buildStatusUpdate(result.statusExit, result.dateExit);
|
|
98
150
|
if (update) {
|
|
99
151
|
updatesByPath.set(result.path, update);
|
|
152
|
+
cacheStatusUpdate(result.path, update);
|
|
100
153
|
}
|
|
101
154
|
}
|
|
102
155
|
if (updatesByPath.size === 0) {
|
|
@@ -3,7 +3,7 @@ import React from 'react';
|
|
|
3
3
|
import { render, cleanup } from 'ink-testing-library';
|
|
4
4
|
import { Text } from 'ink';
|
|
5
5
|
import { Effect, Exit } from 'effect';
|
|
6
|
-
import { useGitStatus } from './useGitStatus.js';
|
|
6
|
+
import { useGitStatus, clearGitStatusCache } from './useGitStatus.js';
|
|
7
7
|
import { getGitStatusLimited, getLastCommitDateLimited, } from '../utils/gitStatus.js';
|
|
8
8
|
import { GitError } from '../types/errors.js';
|
|
9
9
|
// Mock the gitStatus module
|
|
@@ -29,6 +29,9 @@ describe('useGitStatus', () => {
|
|
|
29
29
|
});
|
|
30
30
|
beforeEach(() => {
|
|
31
31
|
vi.useFakeTimers();
|
|
32
|
+
// The status cache is module-level and persists across tests; clear it so
|
|
33
|
+
// each test starts from a cold cache.
|
|
34
|
+
clearGitStatusCache();
|
|
32
35
|
mockGetGitStatus.mockClear();
|
|
33
36
|
mockGetLastCommitDate.mockClear();
|
|
34
37
|
// Default: return a date for all worktrees
|
|
@@ -66,6 +69,27 @@ describe('useGitStatus', () => {
|
|
|
66
69
|
expect(hookResult[0]?.gitStatus).toEqual(gitStatus1);
|
|
67
70
|
expect(hookResult[1]?.gitStatus).toEqual(gitStatus2);
|
|
68
71
|
});
|
|
72
|
+
it('should hydrate from cache on remount so status shows immediately', async () => {
|
|
73
|
+
const worktrees = [createWorktree('/path1')];
|
|
74
|
+
const gitStatus1 = createGitStatus(7, 2);
|
|
75
|
+
mockGetGitStatus.mockReturnValue(Effect.succeed(gitStatus1));
|
|
76
|
+
let hookResult = [];
|
|
77
|
+
const TestComponent = () => {
|
|
78
|
+
hookResult = useGitStatus(worktrees, 'main', 100);
|
|
79
|
+
return React.createElement(Text, null, 'test');
|
|
80
|
+
};
|
|
81
|
+
// First mount: fetch populates the module-level cache.
|
|
82
|
+
const first = render(React.createElement(TestComponent));
|
|
83
|
+
await vi.waitFor(() => {
|
|
84
|
+
expect(hookResult[0]?.gitStatus).toEqual(gitStatus1);
|
|
85
|
+
});
|
|
86
|
+
first.unmount();
|
|
87
|
+
// Remount: cached status must be present on the very first render, before
|
|
88
|
+
// any new fetch resolves, so the menu does not flash "[fetching...]".
|
|
89
|
+
hookResult = [];
|
|
90
|
+
render(React.createElement(TestComponent));
|
|
91
|
+
expect(hookResult[0]?.gitStatus).toEqual(gitStatus1);
|
|
92
|
+
});
|
|
69
93
|
it('should handle empty worktree array', () => {
|
|
70
94
|
let hookResult = [];
|
|
71
95
|
const TestComponent = () => {
|
|
@@ -22,8 +22,6 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
22
22
|
private resizeSuppressTimers;
|
|
23
23
|
private restoreDeferTimers;
|
|
24
24
|
private restoreDeferDeadlines;
|
|
25
|
-
private cursorRedrawSessions;
|
|
26
|
-
private cursorRedrawTimers;
|
|
27
25
|
private spawn;
|
|
28
26
|
private resolvePreset;
|
|
29
27
|
detectTerminalState(session: Session): SessionState;
|
|
@@ -44,9 +42,6 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
44
42
|
constructor();
|
|
45
43
|
private createTerminal;
|
|
46
44
|
private shouldResetRestoreScrollback;
|
|
47
|
-
private hasCursorAddressedRedraw;
|
|
48
|
-
private markCursorRedrawActive;
|
|
49
|
-
private handleScrollbackGrowthDuringRedraw;
|
|
50
45
|
private getRestoreSnapshot;
|
|
51
46
|
private getViewportRedrawSnapshot;
|
|
52
47
|
/**
|
|
@@ -94,7 +89,6 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
94
89
|
private armRestoreDeferTimer;
|
|
95
90
|
private fireRestoreDefer;
|
|
96
91
|
private cancelRestoreDefer;
|
|
97
|
-
private clearCursorRedrawTracking;
|
|
98
92
|
cancelAutoApproval(sessionId: string, reason?: string): void;
|
|
99
93
|
toggleAutoApprovalForWorktree(worktreePath: string): boolean;
|
|
100
94
|
isAutoApprovalDisabledForWorktree(worktreePath: string): boolean;
|
|
@@ -33,11 +33,6 @@ const RESIZE_SUPPRESS_MS = 250;
|
|
|
33
33
|
// streaming session still restores within a small bounded delay.
|
|
34
34
|
const RESTORE_DEFER_QUIET_MS = 80;
|
|
35
35
|
const RESTORE_DEFER_MAX_MS = 250;
|
|
36
|
-
// Cursor-addressed redraws (progress bars, spinners, TUIs) can leave transient
|
|
37
|
-
// rows in scrollback if the viewport scrolls before those rows are overwritten.
|
|
38
|
-
// Keep a short generic redraw window so restore can skip that ghost-bearing
|
|
39
|
-
// range without depending on any specific CLI output text.
|
|
40
|
-
const REDRAW_SCROLLBACK_QUIET_MS = 500;
|
|
41
36
|
export class SessionManager extends EventEmitter {
|
|
42
37
|
sessions;
|
|
43
38
|
waitingWithBottomBorder = new Map();
|
|
@@ -49,8 +44,6 @@ export class SessionManager extends EventEmitter {
|
|
|
49
44
|
resizeSuppressTimers = new Map();
|
|
50
45
|
restoreDeferTimers = new Map();
|
|
51
46
|
restoreDeferDeadlines = new Map();
|
|
52
|
-
cursorRedrawSessions = new Set();
|
|
53
|
-
cursorRedrawTimers = new Map();
|
|
54
47
|
async spawn(command, args, worktreePath, options = {}) {
|
|
55
48
|
const spawnOptions = {
|
|
56
49
|
name: 'xterm-256color',
|
|
@@ -207,15 +200,6 @@ export class SessionManager extends EventEmitter {
|
|
|
207
200
|
...additionalUpdates,
|
|
208
201
|
}));
|
|
209
202
|
if (oldState !== newState) {
|
|
210
|
-
// While busy, cursor-addressed footer redraws (spinner, token stats,
|
|
211
|
-
// "accept edits on …" line) can push ghost frames into scrollback as
|
|
212
|
-
// chat content scrolls. Once the busy turn ends, advance the restore
|
|
213
|
-
// baseline so the next session restore replays only post-busy
|
|
214
|
-
// scrollback and skips the ghost-bearing range.
|
|
215
|
-
if (oldState === 'busy' && newState !== 'busy') {
|
|
216
|
-
session.restoreScrollbackBaseLine =
|
|
217
|
-
session.terminal.buffer.normal.baseY;
|
|
218
|
-
}
|
|
219
203
|
void Effect.runPromise(executeStatusHook(oldState, newState, session));
|
|
220
204
|
this.emit('sessionStateChanged', session);
|
|
221
205
|
}
|
|
@@ -238,35 +222,6 @@ export class SessionManager extends EventEmitter {
|
|
|
238
222
|
data.includes('\x1b[3J') ||
|
|
239
223
|
data.includes('\x1bc'));
|
|
240
224
|
}
|
|
241
|
-
hasCursorAddressedRedraw(data) {
|
|
242
|
-
return (
|
|
243
|
-
// CSI cursor movement/positioning, erase, and scroll controls.
|
|
244
|
-
/\x1b\[[0-?]*[ -/]*[ABCDEFGHJKSTX`abcdefg]/.test(data) ||
|
|
245
|
-
// DEC save/restore cursor.
|
|
246
|
-
/\x1b[78]/.test(data) ||
|
|
247
|
-
// Synchronized output mode usually brackets full-frame redraws.
|
|
248
|
-
/\x1b\[\?2026[hl]/.test(data));
|
|
249
|
-
}
|
|
250
|
-
markCursorRedrawActive(session) {
|
|
251
|
-
this.cursorRedrawSessions.add(session.id);
|
|
252
|
-
const existing = this.cursorRedrawTimers.get(session.id);
|
|
253
|
-
if (existing !== undefined) {
|
|
254
|
-
clearTimeout(existing);
|
|
255
|
-
}
|
|
256
|
-
const timer = setTimeout(() => {
|
|
257
|
-
this.cursorRedrawTimers.delete(session.id);
|
|
258
|
-
this.cursorRedrawSessions.delete(session.id);
|
|
259
|
-
}, REDRAW_SCROLLBACK_QUIET_MS);
|
|
260
|
-
this.cursorRedrawTimers.set(session.id, timer);
|
|
261
|
-
}
|
|
262
|
-
handleScrollbackGrowthDuringRedraw(session, beforeBaseY) {
|
|
263
|
-
const afterBaseY = session.terminal.buffer.normal.baseY;
|
|
264
|
-
if (afterBaseY <= beforeBaseY ||
|
|
265
|
-
!this.cursorRedrawSessions.has(session.id)) {
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
session.restoreScrollbackBaseLine = Math.max(session.restoreScrollbackBaseLine, afterBaseY);
|
|
269
|
-
}
|
|
270
225
|
getRestoreSnapshot(session) {
|
|
271
226
|
const activeBuffer = session.terminal.buffer.active;
|
|
272
227
|
if (activeBuffer.type !== 'normal') {
|
|
@@ -457,13 +412,8 @@ export class SessionManager extends EventEmitter {
|
|
|
457
412
|
setupDataHandler(session) {
|
|
458
413
|
// This handler always runs for all data
|
|
459
414
|
session.process.onData((data) => {
|
|
460
|
-
const beforeBaseY = session.terminal.buffer.normal.baseY;
|
|
461
|
-
if (this.hasCursorAddressedRedraw(data)) {
|
|
462
|
-
this.markCursorRedrawActive(session);
|
|
463
|
-
}
|
|
464
415
|
// Write data to virtual terminal
|
|
465
416
|
session.terminal.write(data);
|
|
466
|
-
this.handleScrollbackGrowthDuringRedraw(session, beforeBaseY);
|
|
467
417
|
if (this.shouldResetRestoreScrollback(data)) {
|
|
468
418
|
session.restoreScrollbackBaseLine =
|
|
469
419
|
session.terminal.buffer.normal.baseY;
|
|
@@ -706,14 +656,6 @@ export class SessionManager extends EventEmitter {
|
|
|
706
656
|
}
|
|
707
657
|
this.restoreDeferDeadlines.delete(sessionId);
|
|
708
658
|
}
|
|
709
|
-
clearCursorRedrawTracking(sessionId) {
|
|
710
|
-
this.cursorRedrawSessions.delete(sessionId);
|
|
711
|
-
const timer = this.cursorRedrawTimers.get(sessionId);
|
|
712
|
-
if (timer !== undefined) {
|
|
713
|
-
clearTimeout(timer);
|
|
714
|
-
this.cursorRedrawTimers.delete(sessionId);
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
659
|
cancelAutoApproval(sessionId, reason = 'User input received') {
|
|
718
660
|
const session = this.sessions.get(sessionId);
|
|
719
661
|
if (!session) {
|
|
@@ -791,7 +733,6 @@ export class SessionManager extends EventEmitter {
|
|
|
791
733
|
clearTimeout(resizeTimer);
|
|
792
734
|
this.resizeSuppressTimers.delete(sessionId);
|
|
793
735
|
}
|
|
794
|
-
this.clearCursorRedrawTracking(sessionId);
|
|
795
736
|
this.cancelRestoreDefer(sessionId);
|
|
796
737
|
this.emit('sessionDestroyed', session);
|
|
797
738
|
}
|
|
@@ -852,7 +793,6 @@ export class SessionManager extends EventEmitter {
|
|
|
852
793
|
clearTimeout(resizeTimer);
|
|
853
794
|
this.resizeSuppressTimers.delete(sessionId);
|
|
854
795
|
}
|
|
855
|
-
this.clearCursorRedrawTracking(sessionId);
|
|
856
796
|
this.cancelRestoreDefer(sessionId);
|
|
857
797
|
this.emit('sessionDestroyed', session);
|
|
858
798
|
},
|
|
@@ -889,7 +889,7 @@ describe('SessionManager', () => {
|
|
|
889
889
|
mockPty.emit('data', 'ordinary output\n');
|
|
890
890
|
expect(session.restoreScrollbackBaseLine).toBe(0);
|
|
891
891
|
});
|
|
892
|
-
it('should
|
|
892
|
+
it('should keep scrollback that grows during cursor-addressed redraws', async () => {
|
|
893
893
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
894
894
|
id: '1',
|
|
895
895
|
name: 'Main',
|
|
@@ -903,38 +903,7 @@ describe('SessionManager', () => {
|
|
|
903
903
|
normalBuffer.baseY = 12;
|
|
904
904
|
});
|
|
905
905
|
mockPty.emit('data', '\x1b[Hredrawn status\n');
|
|
906
|
-
expect(session.restoreScrollbackBaseLine).toBe(
|
|
907
|
-
});
|
|
908
|
-
it('should keep cursor-addressed redraw tracking active for a short quiet window', async () => {
|
|
909
|
-
vi.useFakeTimers();
|
|
910
|
-
try {
|
|
911
|
-
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
912
|
-
id: '1',
|
|
913
|
-
name: 'Main',
|
|
914
|
-
command: 'claude',
|
|
915
|
-
});
|
|
916
|
-
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
917
|
-
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
918
|
-
const normalBuffer = session.terminal.buffer.normal;
|
|
919
|
-
normalBuffer.baseY = 10;
|
|
920
|
-
vi.mocked(session.terminal.write).mockImplementation(() => {
|
|
921
|
-
if (normalBuffer.baseY === 10) {
|
|
922
|
-
return;
|
|
923
|
-
}
|
|
924
|
-
normalBuffer.baseY++;
|
|
925
|
-
});
|
|
926
|
-
mockPty.emit('data', '\x1b[Hredraw frame');
|
|
927
|
-
normalBuffer.baseY = 11;
|
|
928
|
-
mockPty.emit('data', 'plain continuation that scrolls');
|
|
929
|
-
expect(session.restoreScrollbackBaseLine).toBe(12);
|
|
930
|
-
vi.advanceTimersByTime(501);
|
|
931
|
-
normalBuffer.baseY = 20;
|
|
932
|
-
mockPty.emit('data', 'ordinary output after quiet window');
|
|
933
|
-
expect(session.restoreScrollbackBaseLine).toBe(12);
|
|
934
|
-
}
|
|
935
|
-
finally {
|
|
936
|
-
vi.useRealTimers();
|
|
937
|
-
}
|
|
906
|
+
expect(session.restoreScrollbackBaseLine).toBe(0);
|
|
938
907
|
});
|
|
939
908
|
it('should flush live session data after the restore snapshot completes', async () => {
|
|
940
909
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
@@ -1059,7 +1028,7 @@ describe('SessionManager', () => {
|
|
|
1059
1028
|
vi.useRealTimers();
|
|
1060
1029
|
}
|
|
1061
1030
|
});
|
|
1062
|
-
it('should
|
|
1031
|
+
it('should retain the restore baseline when transitioning out of busy', async () => {
|
|
1063
1032
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
1064
1033
|
id: '1',
|
|
1065
1034
|
name: 'Main',
|
|
@@ -1072,21 +1041,6 @@ describe('SessionManager', () => {
|
|
|
1072
1041
|
await session.stateMutex.update(data => ({ ...data, state: 'busy' }));
|
|
1073
1042
|
const updateState = sessionManager.updateSessionState.bind(sessionManager);
|
|
1074
1043
|
await updateState(session, 'idle');
|
|
1075
|
-
expect(session.restoreScrollbackBaseLine).toBe(42);
|
|
1076
|
-
});
|
|
1077
|
-
it('should not advance the restore baseline on transitions that do not leave busy', async () => {
|
|
1078
|
-
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
1079
|
-
id: '1',
|
|
1080
|
-
name: 'Main',
|
|
1081
|
-
command: 'claude',
|
|
1082
|
-
});
|
|
1083
|
-
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
1084
|
-
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
1085
|
-
session.terminal.buffer.normal.baseY = 42;
|
|
1086
|
-
session.restoreScrollbackBaseLine = 5;
|
|
1087
|
-
await session.stateMutex.update(data => ({ ...data, state: 'idle' }));
|
|
1088
|
-
const updateState = sessionManager.updateSessionState.bind(sessionManager);
|
|
1089
|
-
await updateState(session, 'busy');
|
|
1090
1044
|
expect(session.restoreScrollbackBaseLine).toBe(5);
|
|
1091
1045
|
});
|
|
1092
1046
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "4.1
|
|
3
|
+
"version": "4.2.1",
|
|
4
4
|
"description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Kodai Kabasawa",
|
|
@@ -41,11 +41,11 @@
|
|
|
41
41
|
"bin"
|
|
42
42
|
],
|
|
43
43
|
"optionalDependencies": {
|
|
44
|
-
"@kodaikabasawa/ccmanager-darwin-arm64": "4.1
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "4.1
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "4.1
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "4.1
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "4.1
|
|
44
|
+
"@kodaikabasawa/ccmanager-darwin-arm64": "4.2.1",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "4.2.1",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "4.2.1",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "4.2.1",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "4.2.1"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|