ccmanager 4.1.23 → 4.1.24

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