ccmanager 3.12.6 → 4.0.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.
@@ -102,60 +102,126 @@ export function extractBranchParts(branchName) {
102
102
  return { name: branchName };
103
103
  }
104
104
  /**
105
- * Prepares worktree content for display with plain and colored versions.
105
+ * One pass over sessions: group by worktree path and track latest lastAccessedAt per path.
106
106
  */
107
- export function prepareWorktreeItems(worktrees, sessions) {
108
- return worktrees.map(wt => {
109
- const session = sessions.find(s => s.worktreePath === wt.path);
110
- const stateData = session?.stateMutex.getSnapshot();
111
- const status = stateData
112
- ? ` [${getStatusDisplay(stateData.state, stateData.backgroundTaskCount, stateData.teamMemberCount)}]`
113
- : '';
114
- const fullBranchName = wt.branch
115
- ? wt.branch.replace('refs/heads/', '')
116
- : 'detached';
117
- const branchName = truncateString(fullBranchName, MAX_BRANCH_NAME_LENGTH);
118
- const isMain = wt.isMainWorktree ? ' (main)' : '';
119
- const baseLabel = `${branchName}${isMain}${status}`;
120
- let fileChanges = '';
121
- let aheadBehind = '';
122
- let parentBranch = '';
123
- let error = '';
124
- if (wt.gitStatus) {
125
- fileChanges = formatGitFileChanges(wt.gitStatus);
126
- aheadBehind = formatGitAheadBehind(wt.gitStatus);
127
- parentBranch = formatParentBranch(wt.gitStatus.parentBranch, fullBranchName);
107
+ function indexSessionsByWorktree(sessions) {
108
+ const byWorktreePath = new Map();
109
+ const maxAccessAt = new Map();
110
+ for (const s of sessions) {
111
+ const path = s.worktreePath;
112
+ let list = byWorktreePath.get(path);
113
+ if (!list) {
114
+ list = [];
115
+ byWorktreePath.set(path, list);
128
116
  }
129
- else if (wt.gitStatusError) {
130
- // Format error in red
131
- error = `\x1b[31m[git error]\x1b[0m`;
117
+ list.push(s);
118
+ const prevMax = maxAccessAt.get(path) ?? 0;
119
+ if (s.lastAccessedAt > prevMax) {
120
+ maxAccessAt.set(path, s.lastAccessedAt);
132
121
  }
133
- else {
134
- // Show fetching status in dim gray
135
- fileChanges = '\x1b[90m[fetching...]\x1b[0m';
136
- }
137
- // Format last commit date as dim relative time
138
- const lastCommitDate = wt.lastCommitDate
139
- ? `\x1b[90m${formatRelativeDate(wt.lastCommitDate)}\x1b[0m`
140
- : '';
122
+ }
123
+ return { byWorktreePath, maxAccessAt };
124
+ }
125
+ function displaySuffix(session, multipleForWorktree) {
126
+ if (multipleForWorktree) {
127
+ return session.sessionName
128
+ ? `: ${session.sessionName}`
129
+ : ` #${session.sessionNumber}`;
130
+ }
131
+ return session.sessionName ? `: ${session.sessionName}` : '';
132
+ }
133
+ function gitStatusColumns(wt, fullBranchName) {
134
+ if (wt.gitStatus) {
141
135
  return {
142
- worktree: wt,
143
- session,
144
- baseLabel,
145
- fileChanges,
146
- aheadBehind,
147
- parentBranch,
148
- lastCommitDate,
149
- error,
150
- lengths: {
151
- base: stripAnsi(baseLabel).length,
152
- fileChanges: stripAnsi(fileChanges).length,
153
- aheadBehind: stripAnsi(aheadBehind).length,
154
- parentBranch: stripAnsi(parentBranch).length,
155
- lastCommitDate: stripAnsi(lastCommitDate).length,
156
- },
136
+ fileChanges: formatGitFileChanges(wt.gitStatus),
137
+ aheadBehind: formatGitAheadBehind(wt.gitStatus),
138
+ parentBranch: formatParentBranch(wt.gitStatus.parentBranch, fullBranchName),
157
139
  };
158
- });
140
+ }
141
+ if (wt.gitStatusError) {
142
+ return {
143
+ fileChanges: '',
144
+ aheadBehind: '',
145
+ parentBranch: '',
146
+ error: `\x1b[31m[git error]\x1b[0m`,
147
+ };
148
+ }
149
+ return {
150
+ fileChanges: '\x1b[90m[fetching...]\x1b[0m',
151
+ aheadBehind: '',
152
+ parentBranch: '',
153
+ };
154
+ }
155
+ /**
156
+ * Build a single SessionItem row for display.
157
+ */
158
+ function buildSessionItem(wt, session, sessionSuffix) {
159
+ const stateData = session?.stateMutex.getSnapshot();
160
+ const status = stateData
161
+ ? ` [${getStatusDisplay(stateData.state, stateData.backgroundTaskCount, stateData.teamMemberCount)}]`
162
+ : '';
163
+ const fullBranchName = wt.branch
164
+ ? wt.branch.replace('refs/heads/', '')
165
+ : 'detached';
166
+ const branchName = truncateString(fullBranchName, MAX_BRANCH_NAME_LENGTH);
167
+ const isMain = wt.isMainWorktree ? ' (main)' : '';
168
+ const baseLabel = `${branchName}${isMain}${sessionSuffix}${status}`;
169
+ const { fileChanges, aheadBehind, parentBranch, error } = gitStatusColumns(wt, fullBranchName);
170
+ const lastCommitDate = wt.lastCommitDate
171
+ ? `\x1b[90m${formatRelativeDate(wt.lastCommitDate)}\x1b[0m`
172
+ : '';
173
+ return {
174
+ worktree: wt,
175
+ session,
176
+ baseLabel,
177
+ fileChanges,
178
+ aheadBehind,
179
+ parentBranch,
180
+ lastCommitDate,
181
+ error,
182
+ lengths: {
183
+ base: stripAnsi(baseLabel).length,
184
+ fileChanges: stripAnsi(fileChanges).length,
185
+ aheadBehind: stripAnsi(aheadBehind).length,
186
+ parentBranch: stripAnsi(parentBranch).length,
187
+ lastCommitDate: stripAnsi(lastCommitDate).length,
188
+ },
189
+ };
190
+ }
191
+ /**
192
+ * Prepares session items for display.
193
+ * Supports multiple sessions per worktree.
194
+ * When sortByLastSession is true, worktrees are sorted by the most recent
195
+ * session lastAccessedAt timestamp (descending), and sessions within each
196
+ * worktree are also sorted by lastAccessedAt.
197
+ */
198
+ export function prepareSessionItems(worktrees, sessions, options) {
199
+ const { byWorktreePath, maxAccessAt } = indexSessionsByWorktree(sessions);
200
+ const items = [];
201
+ const orderedWorktrees = options?.sortByLastSession && sessions.length > 0
202
+ ? [...worktrees].sort((a, b) => {
203
+ const timeA = maxAccessAt.get(a.path);
204
+ const timeB = maxAccessAt.get(b.path);
205
+ if (timeA === undefined && timeB === undefined)
206
+ return 0;
207
+ return (timeB ?? 0) - (timeA ?? 0);
208
+ })
209
+ : worktrees;
210
+ for (const wt of orderedWorktrees) {
211
+ const wtSessions = byWorktreePath.get(wt.path) ?? [];
212
+ if (wtSessions.length === 0) {
213
+ items.push(buildSessionItem(wt, undefined, ''));
214
+ continue;
215
+ }
216
+ const ordered = wtSessions.length > 1
217
+ ? [...wtSessions].sort((a, b) => b.lastAccessedAt - a.lastAccessedAt)
218
+ : wtSessions;
219
+ const multiple = ordered.length > 1;
220
+ for (const session of ordered) {
221
+ items.push(buildSessionItem(wt, session, displaySuffix(session, multiple)));
222
+ }
223
+ }
224
+ return items;
159
225
  }
160
226
  /**
161
227
  * Calculates column positions based on content widths.
@@ -194,7 +260,7 @@ function padTo(str, visibleLength, column) {
194
260
  /**
195
261
  * Assembles the final worktree label with proper column alignment
196
262
  */
197
- export function assembleWorktreeLabel(item, columns) {
263
+ export function assembleSessionLabel(item, columns) {
198
264
  // If there's an error, just show the base label with error appended
199
265
  if (item.error) {
200
266
  return `${item.baseLabel} ${item.error}`;
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { generateWorktreeDirectory, extractBranchParts, truncateString, prepareWorktreeItems, calculateColumnPositions, assembleWorktreeLabel, } from './worktreeUtils.js';
2
+ import { generateWorktreeDirectory, extractBranchParts, truncateString, prepareSessionItems, calculateColumnPositions, assembleSessionLabel, } 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';
@@ -112,7 +112,7 @@ describe('truncateString', () => {
112
112
  expect(truncateString('abcd', 3)).toBe('...');
113
113
  });
114
114
  });
115
- describe('prepareWorktreeItems', () => {
115
+ describe('prepareSessionItems', () => {
116
116
  const mockWorktree = {
117
117
  path: '/path/to/worktree',
118
118
  branch: 'feature/test-branch',
@@ -123,6 +123,8 @@ describe('prepareWorktreeItems', () => {
123
123
  const mockSession = {
124
124
  id: 'test-session',
125
125
  worktreePath: '/path/to/worktree',
126
+ sessionNumber: 1,
127
+ lastAccessedAt: Date.now(),
126
128
  process: {},
127
129
  output: [],
128
130
  outputHistory: [],
@@ -140,17 +142,17 @@ describe('prepareWorktreeItems', () => {
140
142
  stateDetector: createStateDetector('claude'),
141
143
  };
142
144
  it('should prepare basic worktree without git status', () => {
143
- const items = prepareWorktreeItems([mockWorktree], []);
145
+ const items = prepareSessionItems([mockWorktree], []);
144
146
  expect(items).toHaveLength(1);
145
147
  expect(items[0]?.baseLabel).toBe('feature/test-branch');
146
148
  });
147
149
  it('should include session status in label', () => {
148
- const items = prepareWorktreeItems([mockWorktree], [mockSession]);
150
+ const items = prepareSessionItems([mockWorktree], [mockSession]);
149
151
  expect(items[0]?.baseLabel).toContain('[○ Idle]');
150
152
  });
151
153
  it('should mark main worktree', () => {
152
154
  const mainWorktree = { ...mockWorktree, isMainWorktree: true };
153
- const items = prepareWorktreeItems([mainWorktree], []);
155
+ const items = prepareSessionItems([mainWorktree], []);
154
156
  expect(items[0]?.baseLabel).toContain('(main)');
155
157
  });
156
158
  it('should truncate long branch names', () => {
@@ -158,7 +160,7 @@ describe('prepareWorktreeItems', () => {
158
160
  ...mockWorktree,
159
161
  branch: 'feature/this-is-a-very-long-branch-name-that-should-be-truncated',
160
162
  };
161
- const items = prepareWorktreeItems([longBranch], []);
163
+ const items = prepareSessionItems([longBranch], []);
162
164
  expect(items[0]?.baseLabel.length).toBeLessThanOrEqual(80); // 70 + status + default
163
165
  });
164
166
  });
@@ -204,7 +206,7 @@ describe('column alignment', () => {
204
206
  it('should assemble label with proper alignment', () => {
205
207
  const item = mockItems[0];
206
208
  const columns = calculateColumnPositions(mockItems);
207
- const result = assembleWorktreeLabel(item, columns);
209
+ const result = assembleSessionLabel(item, columns);
208
210
  expect(result).toContain('feature/test-branch');
209
211
  expect(result).toContain('\x1b[32m+10\x1b[0m');
210
212
  expect(result).toContain('\x1b[33m↑2 ↓3\x1b[0m');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "3.12.6",
3
+ "version": "4.0.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": "3.12.6",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "3.12.6",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "3.12.6",
47
- "@kodaikabasawa/ccmanager-linux-x64": "3.12.6",
48
- "@kodaikabasawa/ccmanager-win32-x64": "3.12.6"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "4.0.0",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "4.0.0",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "4.0.0",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "4.0.0",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "4.0.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",