ccmanager 4.3.1 → 4.4.0

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.
Files changed (51) hide show
  1. package/README.md +54 -0
  2. package/dist/components/App.js +92 -30
  3. package/dist/components/App.test.js +86 -0
  4. package/dist/components/Dashboard.js +10 -3
  5. package/dist/components/DeleteWorktree.js +2 -13
  6. package/dist/components/Menu.js +28 -17
  7. package/dist/components/Menu.test.js +37 -1
  8. package/dist/components/RestoreSessions.d.ts +19 -0
  9. package/dist/components/RestoreSessions.js +28 -0
  10. package/dist/components/SessionActions.d.ts +17 -2
  11. package/dist/components/SessionActions.js +19 -17
  12. package/dist/components/SessionActions.test.d.ts +1 -0
  13. package/dist/components/SessionActions.test.js +94 -0
  14. package/dist/hooks/useAvailableLabelWidth.d.ts +6 -0
  15. package/dist/hooks/useAvailableLabelWidth.js +15 -0
  16. package/dist/services/config/globalConfigManager.js +3 -12
  17. package/dist/services/globalSessionOrchestrator.d.ts +7 -0
  18. package/dist/services/globalSessionOrchestrator.js +40 -0
  19. package/dist/services/globalSessionOrchestrator.restore.test.d.ts +1 -0
  20. package/dist/services/globalSessionOrchestrator.restore.test.js +76 -0
  21. package/dist/services/projectManager.js +3 -11
  22. package/dist/services/sessionManager.d.ts +6 -0
  23. package/dist/services/sessionManager.js +16 -0
  24. package/dist/services/sessionRestoreStore.d.ts +75 -0
  25. package/dist/services/sessionRestoreStore.js +138 -0
  26. package/dist/services/sessionRestoreStore.test.d.ts +1 -0
  27. package/dist/services/sessionRestoreStore.test.js +92 -0
  28. package/dist/services/sessionRestorer.d.ts +46 -0
  29. package/dist/services/sessionRestorer.js +123 -0
  30. package/dist/services/sessionRestorer.test.d.ts +1 -0
  31. package/dist/services/sessionRestorer.test.js +163 -0
  32. package/dist/services/worktreeService.d.ts +12 -0
  33. package/dist/services/worktreeService.js +30 -0
  34. package/dist/services/worktreeService.test.js +55 -1
  35. package/dist/types/index.d.ts +3 -2
  36. package/dist/utils/configDir.d.ts +10 -0
  37. package/dist/utils/configDir.js +31 -0
  38. package/dist/utils/errorMessage.d.ts +7 -0
  39. package/dist/utils/errorMessage.js +19 -0
  40. package/dist/utils/filterByQuery.test.js +2 -0
  41. package/dist/utils/gitUtils.d.ts +1 -0
  42. package/dist/utils/gitUtils.js +15 -0
  43. package/dist/utils/hookExecutor.test.js +4 -0
  44. package/dist/utils/worktreeInclude.d.ts +33 -0
  45. package/dist/utils/worktreeInclude.js +100 -0
  46. package/dist/utils/worktreeInclude.test.d.ts +1 -0
  47. package/dist/utils/worktreeInclude.test.js +91 -0
  48. package/dist/utils/worktreeUtils.d.ts +38 -4
  49. package/dist/utils/worktreeUtils.js +76 -17
  50. package/dist/utils/worktreeUtils.test.js +82 -5
  51. package/package.json +6 -6
@@ -206,7 +206,7 @@ function gitStatusColumns(wt, fullBranchName) {
206
206
  function buildSessionItem(wt, session, sessionSuffix) {
207
207
  const stateData = session?.stateMutex.getSnapshot();
208
208
  const status = stateData
209
- ? ` [${getStatusDisplay(stateData.state, stateData.backgroundTaskCount, stateData.teamMemberCount)}]`
209
+ ? `[${getStatusDisplay(stateData.state, stateData.backgroundTaskCount, stateData.teamMemberCount)}]`
210
210
  : '';
211
211
  const fullBranchName = wt.branch
212
212
  ? wt.branch.replace('refs/heads/', '')
@@ -214,7 +214,7 @@ function buildSessionItem(wt, session, sessionSuffix) {
214
214
  const branchName = truncateString(fullBranchName, MAX_BRANCH_NAME_LENGTH);
215
215
  const isMain = wt.isMainWorktree ? ' (main)' : '';
216
216
  const { displaySuffix: dirSuffix, rawName: rawDirName } = formatWorktreeDirectorySuffix(wt, fullBranchName);
217
- const baseLabel = `${branchName}${dirSuffix}${isMain}${sessionSuffix}${status}`;
217
+ const baseLabel = `${branchName}${dirSuffix}${isMain}${sessionSuffix}`;
218
218
  // Use the full (untruncated) branch name so search still matches the tail
219
219
  // of long branch names; status icons are excluded so they don't match.
220
220
  const rawDirForSearch = rawDirName ? ` @ ${rawDirName}` : '';
@@ -227,6 +227,7 @@ function buildSessionItem(wt, session, sessionSuffix) {
227
227
  worktree: wt,
228
228
  session,
229
229
  baseLabel,
230
+ status,
230
231
  searchableName,
231
232
  fileChanges,
232
233
  aheadBehind,
@@ -235,6 +236,7 @@ function buildSessionItem(wt, session, sessionSuffix) {
235
236
  error,
236
237
  lengths: {
237
238
  base: stripAnsi(baseLabel).length,
239
+ status: stripAnsi(status).length,
238
240
  fileChanges: stripAnsi(fileChanges).length,
239
241
  aheadBehind: stripAnsi(aheadBehind).length,
240
242
  parentBranch: stripAnsi(parentBranch).length,
@@ -277,35 +279,59 @@ export function prepareSessionItems(worktrees, sessions, options) {
277
279
  }
278
280
  return items;
279
281
  }
282
+ /**
283
+ * Visible width of the name portion when the state tag is appended to it
284
+ * instead of getting its own column.
285
+ */
286
+ function inlineBaseLength(item) {
287
+ return (item.lengths.base + (item.lengths.status ? item.lengths.status + 1 : 0));
288
+ }
280
289
  /**
281
290
  * Calculates column positions based on content widths.
291
+ *
292
+ * `availableWidth` is the number of terminal columns the label may occupy
293
+ * (i.e. terminal width minus whatever prefix the caller prepends). When the
294
+ * aligned-status layout would not fit in it, the layout falls back to appending
295
+ * the state tag to the name, which is narrower. Omit it to always align.
282
296
  */
283
- export function calculateColumnPositions(items) {
297
+ export function calculateColumnPositions(items, availableWidth) {
284
298
  // Calculate maximum widths from pre-calculated lengths
285
299
  let maxBranchLength = 0;
300
+ let maxInlineBranchLength = 0;
301
+ let maxStatusLength = 0;
286
302
  let maxFileChangesLength = 0;
287
303
  let maxAheadBehindLength = 0;
288
304
  let maxParentBranchLength = 0;
305
+ let maxLastCommitDateLength = 0;
289
306
  items.forEach(item => {
290
307
  // Skip items with errors for alignment calculation
291
308
  if (item.error)
292
309
  return;
293
310
  maxBranchLength = Math.max(maxBranchLength, item.lengths.base);
311
+ maxInlineBranchLength = Math.max(maxInlineBranchLength, inlineBaseLength(item));
312
+ maxStatusLength = Math.max(maxStatusLength, item.lengths.status);
294
313
  maxFileChangesLength = Math.max(maxFileChangesLength, item.lengths.fileChanges);
295
314
  maxAheadBehindLength = Math.max(maxAheadBehindLength, item.lengths.aheadBehind);
296
315
  maxParentBranchLength = Math.max(maxParentBranchLength, item.lengths.parentBranch);
316
+ maxLastCommitDateLength = Math.max(maxLastCommitDateLength, item.lengths.lastCommitDate);
297
317
  });
298
- // Simple column positioning
299
- const fileChangesColumn = maxBranchLength + MIN_COLUMN_PADDING;
300
- const aheadBehindColumn = fileChangesColumn + maxFileChangesLength + MIN_COLUMN_PADDING + 2;
301
- const parentBranchColumn = aheadBehindColumn + maxAheadBehindLength + MIN_COLUMN_PADDING + 2;
302
- const lastCommitDateColumn = parentBranchColumn + maxParentBranchLength + MIN_COLUMN_PADDING + 2;
303
- return {
304
- fileChanges: fileChangesColumn,
305
- aheadBehind: aheadBehindColumn,
306
- parentBranch: parentBranchColumn,
307
- lastCommitDate: lastCommitDateColumn,
318
+ // Simple column positioning. `branchWidth` is how much room the name portion
319
+ // gets, `statusWidth` is 0 when the state tag rides along with the name.
320
+ const layout = (branchWidth, statusWidth) => {
321
+ const fileChanges = branchWidth + MIN_COLUMN_PADDING;
322
+ const aheadBehind = fileChanges + maxFileChangesLength + MIN_COLUMN_PADDING + 2;
323
+ const parentBranch = aheadBehind + maxAheadBehindLength + MIN_COLUMN_PADDING + 2;
324
+ const status = parentBranch + maxParentBranchLength + MIN_COLUMN_PADDING + 2;
325
+ const lastCommitDate = status + (statusWidth ? statusWidth + MIN_COLUMN_PADDING : 0);
326
+ return { fileChanges, aheadBehind, parentBranch, status, lastCommitDate };
308
327
  };
328
+ const aligned = layout(maxBranchLength, maxStatusLength);
329
+ const fits = availableWidth === undefined ||
330
+ aligned.lastCommitDate + maxLastCommitDateLength <= availableWidth;
331
+ if (fits) {
332
+ return { ...aligned, alignStatus: true };
333
+ }
334
+ return { ...layout(maxInlineBranchLength, 0), alignStatus: false };
309
335
  }
310
336
  // Pad string to column position
311
337
  function padTo(str, visibleLength, column) {
@@ -315,12 +341,18 @@ function padTo(str, visibleLength, column) {
315
341
  * Assembles the final worktree label with proper column alignment
316
342
  */
317
343
  export function assembleSessionLabel(item, columns) {
318
- // If there's an error, just show the base label with error appended
344
+ const inlineStatus = item.status
345
+ ? `${item.baseLabel} ${item.status}`
346
+ : item.baseLabel;
347
+ // If there's an error, just show the base label with error appended.
348
+ // Error rows carry no columns at all, so the state tag stays next to the name.
319
349
  if (item.error) {
320
- return `${item.baseLabel} ${item.error}`;
350
+ return `${inlineStatus} ${item.error}`;
321
351
  }
322
- let label = item.baseLabel;
323
- let currentLength = item.lengths.base;
352
+ let label = columns.alignStatus ? item.baseLabel : inlineStatus;
353
+ let currentLength = columns.alignStatus
354
+ ? item.lengths.base
355
+ : inlineBaseLength(item);
324
356
  if (item.fileChanges) {
325
357
  label = padTo(label, currentLength, columns.fileChanges) + item.fileChanges;
326
358
  currentLength = columns.fileChanges + item.lengths.fileChanges;
@@ -334,9 +366,36 @@ export function assembleSessionLabel(item, columns) {
334
366
  padTo(label, currentLength, columns.parentBranch) + item.parentBranch;
335
367
  currentLength = columns.parentBranch + item.lengths.parentBranch;
336
368
  }
369
+ if (columns.alignStatus && item.status) {
370
+ label = padTo(label, currentLength, columns.status) + item.status;
371
+ currentLength = columns.status + item.lengths.status;
372
+ }
337
373
  if (item.lastCommitDate) {
338
374
  label =
339
375
  padTo(label, currentLength, columns.lastCommitDate) + item.lastCommitDate;
340
376
  }
341
377
  return label;
342
378
  }
379
+ /**
380
+ * Whether a worktree may be deleted by CCManager.
381
+ *
382
+ * Two worktrees are off limits:
383
+ * - the main worktree, because git refuses to remove it and the repository
384
+ * would be left without a checkout;
385
+ * - the worktree that contains the current working directory, because removing
386
+ * the directory CCManager is running in breaks the running process.
387
+ *
388
+ * Single source of truth for the rule, shared by the multi-select delete screen
389
+ * and the per-row delete action.
390
+ */
391
+ export function isDeletableWorktree(worktree, cwd = process.cwd()) {
392
+ if (worktree.isMainWorktree)
393
+ return false;
394
+ const resolvedCwd = path.resolve(cwd);
395
+ const resolvedPath = path.resolve(worktree.path);
396
+ if (resolvedCwd === resolvedPath ||
397
+ resolvedCwd.startsWith(resolvedPath + path.sep)) {
398
+ return false;
399
+ }
400
+ return true;
401
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { generateWorktreeDirectory, extractBranchParts, truncateString, prepareSessionItems, calculateColumnPositions, assembleSessionLabel, } from './worktreeUtils.js';
2
+ import { generateWorktreeDirectory, extractBranchParts, truncateString, prepareSessionItems, calculateColumnPositions, assembleSessionLabel, isDeletableWorktree, } from './worktreeUtils.js';
3
3
  import { execSync } from 'child_process';
4
4
  import { Mutex, createInitialSessionStateData } from './mutex.js';
5
5
  import { createStateDetector } from '../services/stateDetector/index.js';
@@ -139,6 +139,7 @@ describe('prepareSessionItems', () => {
139
139
  stateCheckInterval: undefined,
140
140
  isPrimaryCommand: true,
141
141
  presetName: undefined,
142
+ presetId: undefined,
142
143
  detectionStrategy: 'claude',
143
144
  devcontainerConfig: undefined,
144
145
  stateMutex: new Mutex({
@@ -152,9 +153,12 @@ describe('prepareSessionItems', () => {
152
153
  expect(items).toHaveLength(1);
153
154
  expect(items[0]?.baseLabel).toBe('feature/test-branch');
154
155
  });
155
- it('should include session status in label', () => {
156
+ it('should expose the session status separately from the name', () => {
156
157
  const items = prepareSessionItems([mockWorktree], [mockSession]);
157
- expect(items[0]?.baseLabel).toContain('[○ Idle]');
158
+ // The status tag is its own field so it can be rendered as an aligned
159
+ // column; it must not be baked into the name portion.
160
+ expect(items[0]?.status).toBe('[○ Idle]');
161
+ expect(items[0]?.baseLabel).toBe('feature/test-branch');
158
162
  });
159
163
  it('should mark main worktree', () => {
160
164
  const mainWorktree = { ...mockWorktree, isMainWorktree: true };
@@ -249,8 +253,9 @@ describe('prepareSessionItems', () => {
249
253
  sessionName: 'lab',
250
254
  },
251
255
  ]);
252
- // Order must be: branch, dir suffix, (no main), session suffix, status.
253
- expect(items[0]?.baseLabel).toMatch(/^feature\/foo @ foo-api: lab \[.*Idle.*\]$/);
256
+ // Order must be: branch, dir suffix, (no main), session suffix.
257
+ expect(items[0]?.baseLabel).toBe('feature/foo @ foo-api: lab');
258
+ expect(items[0]?.status).toMatch(/^\[.*Idle.*\]$/);
254
259
  });
255
260
  it('does not break column alignment when a dir suffix is appended', () => {
256
261
  const items = prepareSessionItems([
@@ -277,6 +282,7 @@ describe('column alignment', () => {
277
282
  {
278
283
  worktree: {},
279
284
  baseLabel: 'feature/test-branch',
285
+ status: '',
280
286
  searchableName: 'feature/test-branch',
281
287
  fileChanges: '\x1b[32m+10\x1b[0m \x1b[31m-5\x1b[0m',
282
288
  aheadBehind: '\x1b[33m↑2 ↓3\x1b[0m',
@@ -284,6 +290,7 @@ describe('column alignment', () => {
284
290
  lastCommitDate: '',
285
291
  lengths: {
286
292
  base: 19, // 'feature/test-branch'.length
293
+ status: 0,
287
294
  fileChanges: 6, // '+10 -5'.length
288
295
  aheadBehind: 5, // '↑2 ↓3'.length
289
296
  parentBranch: 0,
@@ -293,6 +300,7 @@ describe('column alignment', () => {
293
300
  {
294
301
  worktree: {},
295
302
  baseLabel: 'main',
303
+ status: '',
296
304
  searchableName: 'main',
297
305
  fileChanges: '\x1b[32m+2\x1b[0m \x1b[31m-1\x1b[0m',
298
306
  aheadBehind: '\x1b[33m↑1\x1b[0m',
@@ -300,6 +308,7 @@ describe('column alignment', () => {
300
308
  lastCommitDate: '',
301
309
  lengths: {
302
310
  base: 4, // 'main'.length
311
+ status: 0,
303
312
  fileChanges: 5, // '+2 -1'.length
304
313
  aheadBehind: 2, // '↑1'.length
305
314
  parentBranch: 0,
@@ -325,3 +334,71 @@ describe('column alignment', () => {
325
334
  expect(plain.indexOf('+10 -5')).toBe(21); // Should start at column 21
326
335
  });
327
336
  });
337
+ describe('isDeletableWorktree', () => {
338
+ it('should reject the main worktree', () => {
339
+ expect(isDeletableWorktree({ path: '/repo', isMainWorktree: true }, '/somewhere/else')).toBe(false);
340
+ });
341
+ it('should reject the worktree holding the current working directory', () => {
342
+ expect(isDeletableWorktree({ path: '/repo/worktrees/feature', isMainWorktree: false }, '/repo/worktrees/feature')).toBe(false);
343
+ });
344
+ it('should reject a worktree that is an ancestor of the current working directory', () => {
345
+ expect(isDeletableWorktree({ path: '/repo/worktrees/feature', isMainWorktree: false }, '/repo/worktrees/feature/src/components')).toBe(false);
346
+ });
347
+ it('should accept a sibling worktree with a shared path prefix', () => {
348
+ // '/repo/worktrees/feature-2' starts with the '/repo/worktrees/feature'
349
+ // string but is a different directory, so it stays deletable.
350
+ expect(isDeletableWorktree({ path: '/repo/worktrees/feature', isMainWorktree: false }, '/repo/worktrees/feature-2')).toBe(true);
351
+ });
352
+ it('should accept an unrelated linked worktree', () => {
353
+ expect(isDeletableWorktree({ path: '/repo/worktrees/feature', isMainWorktree: false }, '/repo')).toBe(true);
354
+ });
355
+ });
356
+ describe('session status column', () => {
357
+ const makeItem = (baseLabel, status, lastCommitDate) => ({
358
+ worktree: {},
359
+ baseLabel,
360
+ status,
361
+ searchableName: baseLabel,
362
+ fileChanges: '',
363
+ aheadBehind: '',
364
+ parentBranch: '',
365
+ lastCommitDate,
366
+ lengths: {
367
+ base: baseLabel.length,
368
+ status: status.length,
369
+ fileChanges: 0,
370
+ aheadBehind: 0,
371
+ parentBranch: 0,
372
+ lastCommitDate: lastCommitDate.length,
373
+ },
374
+ });
375
+ const items = [
376
+ makeItem('feature/a-very-long-branch-name', '[○ Idle]', '1d ago'),
377
+ makeItem('main', '[● Busy]', '3w ago'),
378
+ ];
379
+ it('starts every status tag at the same column, just left of the date', () => {
380
+ const columns = calculateColumnPositions(items, 120);
381
+ expect(columns.alignStatus).toBe(true);
382
+ const labels = items.map(item => assembleSessionLabel(item, columns));
383
+ for (const [index, label] of labels.entries()) {
384
+ expect(label.indexOf(items[index].status)).toBe(columns.status);
385
+ expect(label.indexOf(items[index].lastCommitDate)).toBe(columns.lastCommitDate);
386
+ }
387
+ // The gap between the tag and the date is only the column padding.
388
+ expect(columns.lastCommitDate - columns.status).toBe('[○ Idle]'.length + 2);
389
+ });
390
+ it('falls back to appending the status to the name when too narrow', () => {
391
+ const columns = calculateColumnPositions(items, 40);
392
+ expect(columns.alignStatus).toBe(false);
393
+ expect(assembleSessionLabel(items[0], columns)).toContain('feature/a-very-long-branch-name [○ Idle]');
394
+ expect(assembleSessionLabel(items[1], columns)).toContain('main [● Busy]');
395
+ });
396
+ it('keeps the status next to the name on rows showing a git error', () => {
397
+ const errored = {
398
+ ...makeItem('main', '[○ Idle]', ''),
399
+ error: '[git error]',
400
+ };
401
+ const columns = calculateColumnPositions([...items, errored], 120);
402
+ expect(assembleSessionLabel(errored, columns)).toBe('main [○ Idle] [git error]');
403
+ });
404
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "4.3.1",
3
+ "version": "4.4.0",
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.3.1",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.3.1",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.3.1",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.3.1",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.3.1"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "4.4.0",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "4.4.0",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "4.4.0",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "4.4.0",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "4.4.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",