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.
- package/dist/components/App.js +137 -63
- package/dist/components/App.test.js +16 -30
- package/dist/components/Dashboard.js +3 -3
- package/dist/components/Menu.d.ts +2 -2
- package/dist/components/Menu.js +66 -140
- package/dist/components/Menu.recent-projects.test.js +8 -8
- package/dist/components/Menu.test.js +17 -17
- package/dist/components/Session.js +3 -3
- package/dist/components/SessionActions.d.ts +9 -0
- package/dist/components/SessionActions.js +29 -0
- package/dist/components/SessionRename.d.ts +8 -0
- package/dist/components/SessionRename.js +18 -0
- package/dist/constants/statusIcons.d.ts +3 -0
- package/dist/constants/statusIcons.js +3 -0
- package/dist/services/globalSessionOrchestrator.test.js +11 -5
- package/dist/services/sessionManager.autoApproval.test.js +1 -4
- package/dist/services/sessionManager.d.ts +7 -7
- package/dist/services/sessionManager.effect.test.js +17 -16
- package/dist/services/sessionManager.js +43 -48
- package/dist/services/sessionManager.statePersistence.test.js +3 -6
- package/dist/services/sessionManager.test.js +21 -24
- package/dist/services/worktreeService.d.ts +1 -15
- package/dist/services/worktreeService.js +1 -39
- package/dist/services/worktreeService.sort.test.js +141 -303
- package/dist/types/index.d.ts +37 -6
- package/dist/utils/hookExecutor.test.js +8 -0
- package/dist/utils/worktreeUtils.d.ts +12 -6
- package/dist/utils/worktreeUtils.js +116 -50
- package/dist/utils/worktreeUtils.test.js +9 -7
- package/package.json +6 -6
|
@@ -102,60 +102,126 @@ export function extractBranchParts(branchName) {
|
|
|
102
102
|
return { name: branchName };
|
|
103
103
|
}
|
|
104
104
|
/**
|
|
105
|
-
*
|
|
105
|
+
* One pass over sessions: group by worktree path and track latest lastAccessedAt per path.
|
|
106
106
|
*/
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
?
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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,
|
|
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('
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
+
"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": "
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "
|
|
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",
|