ccmanager 4.1.23 → 4.1.25
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.
|
@@ -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
|
*
|
|
@@ -7,6 +9,11 @@ import { Worktree } from '../types/index.js';
|
|
|
7
9
|
* Both are fetched together so they appear at the same time.
|
|
8
10
|
* Handles cancellation via AbortController.
|
|
9
11
|
*
|
|
12
|
+
* Each poll cycle fetches every worktree concurrently and commits the results in
|
|
13
|
+
* a single state update, skipping the update entirely when nothing changed. This
|
|
14
|
+
* keeps a poll cycle to at most one re-render of the consumer (the menu) instead
|
|
15
|
+
* of one per worktree, so background polling does not stutter keyboard navigation.
|
|
16
|
+
*
|
|
10
17
|
* @param worktrees - Array of worktrees to monitor
|
|
11
18
|
* @param defaultBranch - Default branch for comparisons (null disables polling)
|
|
12
19
|
* @param updateInterval - Polling interval in milliseconds (default: 5000)
|
|
@@ -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
|
*
|
|
@@ -9,78 +61,120 @@ import { getGitStatusLimited, getLastCommitDateLimited, } from '../utils/gitStat
|
|
|
9
61
|
* Both are fetched together so they appear at the same time.
|
|
10
62
|
* Handles cancellation via AbortController.
|
|
11
63
|
*
|
|
64
|
+
* Each poll cycle fetches every worktree concurrently and commits the results in
|
|
65
|
+
* a single state update, skipping the update entirely when nothing changed. This
|
|
66
|
+
* keeps a poll cycle to at most one re-render of the consumer (the menu) instead
|
|
67
|
+
* of one per worktree, so background polling does not stutter keyboard navigation.
|
|
68
|
+
*
|
|
12
69
|
* @param worktrees - Array of worktrees to monitor
|
|
13
70
|
* @param defaultBranch - Default branch for comparisons (null disables polling)
|
|
14
71
|
* @param updateInterval - Polling interval in milliseconds (default: 5000)
|
|
15
72
|
* @returns Array of worktrees with updated gitStatus, gitStatusError, and lastCommitDate fields
|
|
16
73
|
*/
|
|
17
74
|
export function useGitStatus(worktrees, defaultBranch, updateInterval = 5000) {
|
|
18
|
-
const [worktreesWithStatus, setWorktreesWithStatus] = useState(worktrees);
|
|
75
|
+
const [worktreesWithStatus, setWorktreesWithStatus] = useState(() => hydrateFromCache(worktrees));
|
|
19
76
|
useEffect(() => {
|
|
20
77
|
if (!defaultBranch) {
|
|
21
78
|
return;
|
|
22
79
|
}
|
|
23
|
-
const
|
|
24
|
-
const activeRequests = new Map();
|
|
80
|
+
const activeRequests = new Set();
|
|
25
81
|
let isCleanedUp = false;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const [statusExit, dateExit] = await Promise.all([
|
|
29
|
-
Effect.runPromiseExit(getGitStatusLimited(worktree.path), {
|
|
30
|
-
signal: abortController.signal,
|
|
31
|
-
}),
|
|
32
|
-
Effect.runPromiseExit(getLastCommitDateLimited(worktree.path), {
|
|
33
|
-
signal: abortController.signal,
|
|
34
|
-
}),
|
|
35
|
-
]);
|
|
36
|
-
// Update worktree state with both results at once
|
|
37
|
-
handleStatusExit(statusExit, dateExit, worktree.path, setWorktreesWithStatus);
|
|
38
|
-
};
|
|
39
|
-
const scheduleUpdate = (worktree) => {
|
|
82
|
+
let cycleTimeout;
|
|
83
|
+
const fetchStatus = async (worktree) => {
|
|
40
84
|
const abortController = new AbortController();
|
|
41
|
-
activeRequests.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
85
|
+
activeRequests.add(abortController);
|
|
86
|
+
try {
|
|
87
|
+
// Fetch git status and last commit date in parallel
|
|
88
|
+
const [statusExit, dateExit] = await Promise.all([
|
|
89
|
+
Effect.runPromiseExit(getGitStatusLimited(worktree.path), {
|
|
90
|
+
signal: abortController.signal,
|
|
91
|
+
}),
|
|
92
|
+
Effect.runPromiseExit(getLastCommitDateLimited(worktree.path), {
|
|
93
|
+
signal: abortController.signal,
|
|
94
|
+
}),
|
|
95
|
+
]);
|
|
96
|
+
return { path: worktree.path, statusExit, dateExit };
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
activeRequests.delete(abortController);
|
|
100
|
+
}
|
|
57
101
|
};
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
102
|
+
const runCycle = async () => {
|
|
103
|
+
// Fetch all worktrees concurrently. The underlying Effects are already
|
|
104
|
+
// concurrency-limited, so this does not spawn an unbounded number of git
|
|
105
|
+
// subprocesses at once.
|
|
106
|
+
const results = await Promise.all(worktrees.map(worktree => fetchStatus(worktree)));
|
|
107
|
+
if (isCleanedUp) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// Apply every worktree's result in one state update so the consumer
|
|
111
|
+
// re-renders at most once per cycle.
|
|
112
|
+
applyStatusResults(results, setWorktreesWithStatus);
|
|
113
|
+
if (!isCleanedUp) {
|
|
114
|
+
cycleTimeout = setTimeout(() => {
|
|
115
|
+
if (!isCleanedUp) {
|
|
116
|
+
runCycle();
|
|
117
|
+
}
|
|
118
|
+
}, updateInterval);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
setWorktreesWithStatus(hydrateFromCache(worktrees));
|
|
122
|
+
runCycle().catch(() => {
|
|
123
|
+
// Ignore errors - the fetch failed or was aborted
|
|
62
124
|
});
|
|
63
125
|
return () => {
|
|
64
126
|
isCleanedUp = true;
|
|
65
|
-
|
|
127
|
+
if (cycleTimeout) {
|
|
128
|
+
clearTimeout(cycleTimeout);
|
|
129
|
+
}
|
|
66
130
|
activeRequests.forEach(controller => controller.abort());
|
|
67
131
|
};
|
|
68
132
|
}, [worktrees, defaultBranch, updateInterval]);
|
|
69
133
|
return worktreesWithStatus;
|
|
70
134
|
}
|
|
71
135
|
/**
|
|
72
|
-
*
|
|
136
|
+
* Apply a cycle's worth of status results in a single state update.
|
|
73
137
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
138
|
+
* Builds the next worktree array by merging each result onto the matching
|
|
139
|
+
* worktree, but only when the result actually changes a field. When nothing
|
|
140
|
+
* changed, the previous array reference is returned so React skips the re-render
|
|
141
|
+
* (and any downstream items rebuild) entirely.
|
|
76
142
|
*
|
|
77
|
-
* @param
|
|
78
|
-
* @param dateExit - Exit result from commit date Effect
|
|
79
|
-
* @param worktreePath - Path of the worktree being updated
|
|
143
|
+
* @param results - Per-worktree status/date Exit results for this cycle
|
|
80
144
|
* @param setWorktreesWithStatus - State setter function
|
|
81
145
|
*/
|
|
82
|
-
function
|
|
83
|
-
|
|
146
|
+
function applyStatusResults(results, setWorktreesWithStatus) {
|
|
147
|
+
const updatesByPath = new Map();
|
|
148
|
+
for (const result of results) {
|
|
149
|
+
const update = buildStatusUpdate(result.statusExit, result.dateExit);
|
|
150
|
+
if (update) {
|
|
151
|
+
updatesByPath.set(result.path, update);
|
|
152
|
+
cacheStatusUpdate(result.path, update);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (updatesByPath.size === 0) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
setWorktreesWithStatus(prev => {
|
|
159
|
+
let changed = false;
|
|
160
|
+
const next = prev.map(wt => {
|
|
161
|
+
const update = updatesByPath.get(wt.path);
|
|
162
|
+
if (!update || isUpdateNoop(wt, update)) {
|
|
163
|
+
return wt;
|
|
164
|
+
}
|
|
165
|
+
changed = true;
|
|
166
|
+
return { ...wt, ...update };
|
|
167
|
+
});
|
|
168
|
+
return changed ? next : prev;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Build the update object for a single worktree from its Exit results.
|
|
173
|
+
*
|
|
174
|
+
* @returns A partial worktree to merge, or null when there is nothing to update
|
|
175
|
+
* (e.g. the status fetch was interrupted and the commit date failed).
|
|
176
|
+
*/
|
|
177
|
+
function buildStatusUpdate(statusExit, dateExit) {
|
|
84
178
|
const update = {};
|
|
85
179
|
let hasUpdate = false;
|
|
86
180
|
if (Exit.isSuccess(statusExit)) {
|
|
@@ -91,9 +185,8 @@ function handleStatusExit(statusExit, dateExit, worktreePath, setWorktreesWithSt
|
|
|
91
185
|
else if (Exit.isFailure(statusExit)) {
|
|
92
186
|
const failure = Cause.failureOption(statusExit.cause);
|
|
93
187
|
if (Option.isSome(failure)) {
|
|
94
|
-
const gitError = failure.value;
|
|
95
188
|
update.gitStatus = undefined;
|
|
96
|
-
update.gitStatusError = formatGitError(
|
|
189
|
+
update.gitStatusError = formatGitError(failure.value);
|
|
97
190
|
hasUpdate = true;
|
|
98
191
|
}
|
|
99
192
|
}
|
|
@@ -102,9 +195,42 @@ function handleStatusExit(statusExit, dateExit, worktreePath, setWorktreesWithSt
|
|
|
102
195
|
hasUpdate = true;
|
|
103
196
|
}
|
|
104
197
|
// Silently ignore commit date errors (e.g., empty repo)
|
|
105
|
-
|
|
106
|
-
|
|
198
|
+
return hasUpdate ? update : null;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Determine whether applying `update` to `wt` would change any field, so that
|
|
202
|
+
* no-op updates can be dropped before they trigger a re-render.
|
|
203
|
+
*/
|
|
204
|
+
function isUpdateNoop(wt, update) {
|
|
205
|
+
if ('gitStatus' in update &&
|
|
206
|
+
!isSameGitStatus(wt.gitStatus, update.gitStatus)) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
if ('gitStatusError' in update &&
|
|
210
|
+
wt.gitStatusError !== update.gitStatusError) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
if ('lastCommitDate' in update) {
|
|
214
|
+
const prevTime = wt.lastCommitDate?.getTime();
|
|
215
|
+
const nextTime = update.lastCommitDate?.getTime();
|
|
216
|
+
if (prevTime !== nextTime) {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
function isSameGitStatus(a, b) {
|
|
223
|
+
if (a === b) {
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
if (!a || !b) {
|
|
227
|
+
return false;
|
|
107
228
|
}
|
|
229
|
+
return (a.filesAdded === b.filesAdded &&
|
|
230
|
+
a.filesDeleted === b.filesDeleted &&
|
|
231
|
+
a.aheadCount === b.aheadCount &&
|
|
232
|
+
a.behindCount === b.behindCount &&
|
|
233
|
+
a.parentBranch === b.parentBranch);
|
|
108
234
|
}
|
|
109
235
|
/**
|
|
110
236
|
* Format GitError into a user-friendly error message
|
|
@@ -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 = () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.25",
|
|
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.1.25",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "4.1.25",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "4.1.25",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "4.1.25",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "4.1.25"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|