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 timeouts = new Map();
24
- const activeRequests = new Map();
80
+ const activeRequests = new Set();
25
81
  let isCleanedUp = false;
26
- const fetchStatus = async (worktree, abortController) => {
27
- // Fetch git status and last commit date in parallel
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.set(worktree.path, abortController);
42
- fetchStatus(worktree, abortController)
43
- .catch(() => {
44
- // Ignore errors - the fetch failed or was aborted
45
- })
46
- .finally(() => {
47
- const isActive = () => !isCleanedUp && !abortController.signal.aborted;
48
- if (isActive()) {
49
- const timeout = setTimeout(() => {
50
- if (isActive()) {
51
- scheduleUpdate(worktree);
52
- }
53
- }, updateInterval);
54
- timeouts.set(worktree.path, timeout);
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
- setWorktreesWithStatus(worktrees);
59
- // Start fetching for each worktree
60
- worktrees.forEach(worktree => {
61
- scheduleUpdate(worktree);
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
- timeouts.forEach(timeout => clearTimeout(timeout));
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
- * Handle the Exit results from Effect.runPromiseExit and update worktree state
136
+ * Apply a cycle's worth of status results in a single state update.
73
137
  *
74
- * Updates both gitStatus and lastCommitDate in a single state update so they
75
- * appear at the same time in the UI.
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 statusExit - Exit result from git status Effect
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 handleStatusExit(statusExit, dateExit, worktreePath, setWorktreesWithStatus) {
83
- // Build the update object from both results
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(gitError);
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
- if (hasUpdate) {
106
- setWorktreesWithStatus(prev => prev.map(wt => (wt.path === worktreePath ? { ...wt, ...update } : wt)));
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.23",
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.23",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.1.23",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.1.23",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.1.23",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.1.23"
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",