ccmanager 4.1.21 → 4.1.22
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.
|
@@ -25,7 +25,31 @@ export interface SessionItem {
|
|
|
25
25
|
*/
|
|
26
26
|
export declare function formatRelativeDate(date: Date): string;
|
|
27
27
|
export declare function truncateString(str: string, maxLength: number): string;
|
|
28
|
+
/**
|
|
29
|
+
* Sanitize a branch name into the form used for a worktree directory name:
|
|
30
|
+
* slashes become dashes, characters outside [a-zA-Z0-9-_.] are stripped,
|
|
31
|
+
* leading/trailing dashes are removed, and the result is lowercased.
|
|
32
|
+
*
|
|
33
|
+
* This is the single source of truth for that mapping. It is reused both to
|
|
34
|
+
* generate worktree directories (see {@link generateWorktreeDirectory}) and to
|
|
35
|
+
* decide whether an existing directory name already conveys the branch name
|
|
36
|
+
* (see {@link formatWorktreeDirectorySuffix}), so a directory generated from a
|
|
37
|
+
* branch is reliably recognized as a match.
|
|
38
|
+
*/
|
|
39
|
+
export declare function sanitizeNameForDirectory(value: string): string;
|
|
28
40
|
export declare function generateWorktreeDirectory(projectPath: string, branchName: string, pattern?: string): string;
|
|
41
|
+
/**
|
|
42
|
+
* Returns the worktree directory basename to show alongside the branch name,
|
|
43
|
+
* or an empty string when it would be redundant (main worktree, detached,
|
|
44
|
+
* or the directory name matches the branch).
|
|
45
|
+
*
|
|
46
|
+
* The visible suffix is truncated; the raw basename is also returned so
|
|
47
|
+
* callers that want to make it searchable can include it untruncated.
|
|
48
|
+
*/
|
|
49
|
+
export declare function formatWorktreeDirectorySuffix(wt: Worktree, fullBranchName: string): {
|
|
50
|
+
displaySuffix: string;
|
|
51
|
+
rawName: string;
|
|
52
|
+
};
|
|
29
53
|
export declare function extractBranchParts(branchName: string): {
|
|
30
54
|
prefix?: string;
|
|
31
55
|
name: string;
|
|
@@ -5,6 +5,7 @@ import { getStatusDisplay } from '../constants/statusIcons.js';
|
|
|
5
5
|
import { formatGitFileChanges, formatGitAheadBehind, formatParentBranch, } from './gitStatus.js';
|
|
6
6
|
// Constants
|
|
7
7
|
const MAX_BRANCH_NAME_LENGTH = 70; // Maximum characters for branch name display
|
|
8
|
+
const MAX_WORKTREE_DIR_NAME_LENGTH = 30; // Maximum characters for the worktree directory name suffix
|
|
8
9
|
const MIN_COLUMN_PADDING = 2; // Minimum spaces between columns
|
|
9
10
|
/**
|
|
10
11
|
* Format a date as a relative time string (e.g., "2h ago", "3d ago").
|
|
@@ -64,6 +65,24 @@ function getGitRepositoryName(projectPath) {
|
|
|
64
65
|
return path.basename(projectPath);
|
|
65
66
|
}
|
|
66
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Sanitize a branch name into the form used for a worktree directory name:
|
|
70
|
+
* slashes become dashes, characters outside [a-zA-Z0-9-_.] are stripped,
|
|
71
|
+
* leading/trailing dashes are removed, and the result is lowercased.
|
|
72
|
+
*
|
|
73
|
+
* This is the single source of truth for that mapping. It is reused both to
|
|
74
|
+
* generate worktree directories (see {@link generateWorktreeDirectory}) and to
|
|
75
|
+
* decide whether an existing directory name already conveys the branch name
|
|
76
|
+
* (see {@link formatWorktreeDirectorySuffix}), so a directory generated from a
|
|
77
|
+
* branch is reliably recognized as a match.
|
|
78
|
+
*/
|
|
79
|
+
export function sanitizeNameForDirectory(value) {
|
|
80
|
+
return value
|
|
81
|
+
.replace(/\//g, '-') // Replace forward slashes with dashes
|
|
82
|
+
.replace(/[^a-zA-Z0-9-_.]+/g, '') // Remove special characters except dash, dot, underscore
|
|
83
|
+
.replace(/^-+|-+$/g, '') // Remove leading/trailing dashes
|
|
84
|
+
.toLowerCase(); // Convert to lowercase for consistency
|
|
85
|
+
}
|
|
67
86
|
export function generateWorktreeDirectory(projectPath, branchName, pattern) {
|
|
68
87
|
// Default pattern if not specified
|
|
69
88
|
const defaultPattern = '../{branch}';
|
|
@@ -74,12 +93,7 @@ export function generateWorktreeDirectory(projectPath, branchName, pattern) {
|
|
|
74
93
|
switch (name) {
|
|
75
94
|
case 'branch':
|
|
76
95
|
case 'branch-name':
|
|
77
|
-
|
|
78
|
-
sanitizedBranch ??= branchName
|
|
79
|
-
.replace(/\//g, '-') // Replace forward slashes with dashes
|
|
80
|
-
.replace(/[^a-zA-Z0-9-_.]+/g, '') // Remove special characters except dash, dot, underscore
|
|
81
|
-
.replace(/^-+|-+$/g, '') // Remove leading/trailing dashes
|
|
82
|
-
.toLowerCase(); // Convert to lowercase for consistency
|
|
96
|
+
sanitizedBranch ??= sanitizeNameForDirectory(branchName);
|
|
83
97
|
return sanitizedBranch;
|
|
84
98
|
case 'project':
|
|
85
99
|
projectName ??= getGitRepositoryName(projectPath);
|
|
@@ -91,6 +105,40 @@ export function generateWorktreeDirectory(projectPath, branchName, pattern) {
|
|
|
91
105
|
// Ensure the path is relative to the repository root
|
|
92
106
|
return path.normalize(directory);
|
|
93
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Returns the worktree directory basename to show alongside the branch name,
|
|
110
|
+
* or an empty string when it would be redundant (main worktree, detached,
|
|
111
|
+
* or the directory name matches the branch).
|
|
112
|
+
*
|
|
113
|
+
* The visible suffix is truncated; the raw basename is also returned so
|
|
114
|
+
* callers that want to make it searchable can include it untruncated.
|
|
115
|
+
*/
|
|
116
|
+
export function formatWorktreeDirectorySuffix(wt, fullBranchName) {
|
|
117
|
+
if (wt.isMainWorktree)
|
|
118
|
+
return { displaySuffix: '', rawName: '' };
|
|
119
|
+
if (!wt.branch)
|
|
120
|
+
return { displaySuffix: '', rawName: '' };
|
|
121
|
+
if (!wt.path)
|
|
122
|
+
return { displaySuffix: '', rawName: '' };
|
|
123
|
+
const dirName = path.basename(wt.path);
|
|
124
|
+
if (!dirName)
|
|
125
|
+
return { displaySuffix: '', rawName: '' };
|
|
126
|
+
const normalizedDir = sanitizeNameForDirectory(dirName);
|
|
127
|
+
if (!normalizedDir)
|
|
128
|
+
return { displaySuffix: '', rawName: '' };
|
|
129
|
+
const normalizedBranch = sanitizeNameForDirectory(fullBranchName);
|
|
130
|
+
if (normalizedDir === normalizedBranch) {
|
|
131
|
+
return { displaySuffix: '', rawName: '' };
|
|
132
|
+
}
|
|
133
|
+
// Also suppress when the directory matches just the tail segment of the
|
|
134
|
+
// branch (e.g. branch "feature/foo" vs directory "foo").
|
|
135
|
+
const tail = extractBranchParts(fullBranchName).name;
|
|
136
|
+
if (tail && sanitizeNameForDirectory(tail) === normalizedDir) {
|
|
137
|
+
return { displaySuffix: '', rawName: '' };
|
|
138
|
+
}
|
|
139
|
+
const visible = truncateString(dirName, MAX_WORKTREE_DIR_NAME_LENGTH);
|
|
140
|
+
return { displaySuffix: ` @ ${visible}`, rawName: dirName };
|
|
141
|
+
}
|
|
94
142
|
export function extractBranchParts(branchName) {
|
|
95
143
|
const parts = branchName.split('/');
|
|
96
144
|
if (parts.length > 1) {
|
|
@@ -165,10 +213,12 @@ function buildSessionItem(wt, session, sessionSuffix) {
|
|
|
165
213
|
: 'detached';
|
|
166
214
|
const branchName = truncateString(fullBranchName, MAX_BRANCH_NAME_LENGTH);
|
|
167
215
|
const isMain = wt.isMainWorktree ? ' (main)' : '';
|
|
168
|
-
const
|
|
216
|
+
const { displaySuffix: dirSuffix, rawName: rawDirName } = formatWorktreeDirectorySuffix(wt, fullBranchName);
|
|
217
|
+
const baseLabel = `${branchName}${dirSuffix}${isMain}${sessionSuffix}${status}`;
|
|
169
218
|
// Use the full (untruncated) branch name so search still matches the tail
|
|
170
219
|
// of long branch names; status icons are excluded so they don't match.
|
|
171
|
-
const
|
|
220
|
+
const rawDirForSearch = rawDirName ? ` @ ${rawDirName}` : '';
|
|
221
|
+
const searchableName = `${fullBranchName}${rawDirForSearch}${isMain}${sessionSuffix}`;
|
|
172
222
|
const { fileChanges, aheadBehind, parentBranch, error } = gitStatusColumns(wt, fullBranchName);
|
|
173
223
|
const lastCommitDate = wt.lastCommitDate
|
|
174
224
|
? `\x1b[90m${formatRelativeDate(wt.lastCommitDate)}\x1b[0m`
|
|
@@ -113,8 +113,10 @@ describe('truncateString', () => {
|
|
|
113
113
|
});
|
|
114
114
|
});
|
|
115
115
|
describe('prepareSessionItems', () => {
|
|
116
|
+
// The directory basename here matches the sanitized branch tail so the
|
|
117
|
+
// worktree-directory suffix is suppressed in the basic baseLabel tests.
|
|
116
118
|
const mockWorktree = {
|
|
117
|
-
path: '/path/to/
|
|
119
|
+
path: '/path/to/test-branch',
|
|
118
120
|
branch: 'feature/test-branch',
|
|
119
121
|
isMainWorktree: false,
|
|
120
122
|
hasSession: false,
|
|
@@ -122,7 +124,7 @@ describe('prepareSessionItems', () => {
|
|
|
122
124
|
// Simplified mock
|
|
123
125
|
const mockSession = {
|
|
124
126
|
id: 'test-session',
|
|
125
|
-
worktreePath: '/path/to/
|
|
127
|
+
worktreePath: '/path/to/test-branch',
|
|
126
128
|
sessionNumber: 1,
|
|
127
129
|
command: 'claude',
|
|
128
130
|
fallbackArgs: undefined,
|
|
@@ -167,6 +169,108 @@ describe('prepareSessionItems', () => {
|
|
|
167
169
|
const items = prepareSessionItems([longBranch], []);
|
|
168
170
|
expect(items[0]?.baseLabel.length).toBeLessThanOrEqual(80); // 70 + status + default
|
|
169
171
|
});
|
|
172
|
+
describe('worktree directory suffix', () => {
|
|
173
|
+
it('shows the directory name when it differs from the branch', () => {
|
|
174
|
+
const wt = {
|
|
175
|
+
path: '/repos/myproj/worktrees/login-api',
|
|
176
|
+
branch: 'feature/login',
|
|
177
|
+
isMainWorktree: false,
|
|
178
|
+
hasSession: false,
|
|
179
|
+
};
|
|
180
|
+
const items = prepareSessionItems([wt], []);
|
|
181
|
+
expect(items[0]?.baseLabel).toBe('feature/login @ login-api');
|
|
182
|
+
});
|
|
183
|
+
it('hides the directory name when the directory equals the branch tail', () => {
|
|
184
|
+
const wt = {
|
|
185
|
+
path: '/repos/myproj/worktrees/foo',
|
|
186
|
+
branch: 'feature/foo',
|
|
187
|
+
isMainWorktree: false,
|
|
188
|
+
hasSession: false,
|
|
189
|
+
};
|
|
190
|
+
const items = prepareSessionItems([wt], []);
|
|
191
|
+
expect(items[0]?.baseLabel).toBe('feature/foo');
|
|
192
|
+
});
|
|
193
|
+
it('hides the directory name when it matches the sanitized full branch', () => {
|
|
194
|
+
const wt = {
|
|
195
|
+
path: '/repos/myproj/worktrees/feature-foo',
|
|
196
|
+
branch: 'feature/foo',
|
|
197
|
+
isMainWorktree: false,
|
|
198
|
+
hasSession: false,
|
|
199
|
+
};
|
|
200
|
+
const items = prepareSessionItems([wt], []);
|
|
201
|
+
expect(items[0]?.baseLabel).toBe('feature/foo');
|
|
202
|
+
});
|
|
203
|
+
it('hides the directory name for the main worktree', () => {
|
|
204
|
+
const wt = {
|
|
205
|
+
path: '/repos/myproj',
|
|
206
|
+
branch: 'main',
|
|
207
|
+
isMainWorktree: true,
|
|
208
|
+
hasSession: false,
|
|
209
|
+
};
|
|
210
|
+
const items = prepareSessionItems([wt], []);
|
|
211
|
+
expect(items[0]?.baseLabel).toBe('main (main)');
|
|
212
|
+
});
|
|
213
|
+
it('truncates long directory names in the displayed label', () => {
|
|
214
|
+
const longDir = '/repos/myproj/worktrees/this-is-a-very-long-directory-name-that-should-be-truncated';
|
|
215
|
+
const wt = {
|
|
216
|
+
path: longDir,
|
|
217
|
+
branch: 'feature/short',
|
|
218
|
+
isMainWorktree: false,
|
|
219
|
+
hasSession: false,
|
|
220
|
+
};
|
|
221
|
+
const items = prepareSessionItems([wt], []);
|
|
222
|
+
// "feature/short @ " (16 chars) + up to MAX_WORKTREE_DIR_NAME_LENGTH (30)
|
|
223
|
+
const after = items[0]?.baseLabel.split(' @ ')[1] ?? '';
|
|
224
|
+
expect(after.length).toBeLessThanOrEqual(30);
|
|
225
|
+
expect(after.endsWith('...')).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
it('keeps the untruncated directory basename in searchableName', () => {
|
|
228
|
+
const longDir = '/repos/myproj/worktrees/this-is-a-very-long-directory-name-that-should-be-truncated';
|
|
229
|
+
const wt = {
|
|
230
|
+
path: longDir,
|
|
231
|
+
branch: 'feature/short',
|
|
232
|
+
isMainWorktree: false,
|
|
233
|
+
hasSession: false,
|
|
234
|
+
};
|
|
235
|
+
const items = prepareSessionItems([wt], []);
|
|
236
|
+
expect(items[0]?.searchableName).toContain('this-is-a-very-long-directory-name-that-should-be-truncated');
|
|
237
|
+
});
|
|
238
|
+
it('places the directory suffix before session and status markers', () => {
|
|
239
|
+
const wt = {
|
|
240
|
+
path: '/repos/myproj/worktrees/foo-api',
|
|
241
|
+
branch: 'feature/foo',
|
|
242
|
+
isMainWorktree: false,
|
|
243
|
+
hasSession: false,
|
|
244
|
+
};
|
|
245
|
+
const items = prepareSessionItems([wt], [
|
|
246
|
+
{
|
|
247
|
+
...mockSession,
|
|
248
|
+
worktreePath: '/repos/myproj/worktrees/foo-api',
|
|
249
|
+
sessionName: 'lab',
|
|
250
|
+
},
|
|
251
|
+
]);
|
|
252
|
+
// Order must be: branch, dir suffix, (no main), session suffix, status.
|
|
253
|
+
expect(items[0]?.baseLabel).toMatch(/^feature\/foo @ foo-api: lab \[.*Idle.*\]$/);
|
|
254
|
+
});
|
|
255
|
+
it('does not break column alignment when a dir suffix is appended', () => {
|
|
256
|
+
const items = prepareSessionItems([
|
|
257
|
+
{
|
|
258
|
+
path: '/repos/myproj/worktrees/foo-api',
|
|
259
|
+
branch: 'feature/foo',
|
|
260
|
+
isMainWorktree: false,
|
|
261
|
+
hasSession: false,
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
path: '/repos/myproj',
|
|
265
|
+
branch: 'main',
|
|
266
|
+
isMainWorktree: true,
|
|
267
|
+
hasSession: false,
|
|
268
|
+
},
|
|
269
|
+
], []);
|
|
270
|
+
expect(items[0]?.lengths.base).toBe(items[0]?.baseLabel.length);
|
|
271
|
+
expect(items[1]?.lengths.base).toBe(items[1]?.baseLabel.length);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
170
274
|
});
|
|
171
275
|
describe('column alignment', () => {
|
|
172
276
|
const mockItems = [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.22",
|
|
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.
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "4.1.
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "4.1.
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "4.1.
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "4.1.
|
|
44
|
+
"@kodaikabasawa/ccmanager-darwin-arm64": "4.1.22",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "4.1.22",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "4.1.22",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "4.1.22",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "4.1.22"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|