ccmanager 4.1.20 → 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.
@@ -245,6 +245,7 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, onSessionAct
245
245
  worktree: wt,
246
246
  session: entry.session,
247
247
  baseLabel,
248
+ searchableName: `${entry.projectName} :: ${fullBranchName}${isMain}`,
248
249
  fileChanges,
249
250
  aheadBehind,
250
251
  parentBranch,
@@ -10,7 +10,7 @@ import { prepareSessionItems, calculateColumnPositions, assembleSessionLabel, }
10
10
  import { projectManager } from '../services/projectManager.js';
11
11
  import { useSearchMode } from '../hooks/useSearchMode.js';
12
12
  import { useDynamicLimit } from '../hooks/useDynamicLimit.js';
13
- import { filterWorktreesByQuery } from '../utils/filterByQuery.js';
13
+ import { filterSessionItemsByQuery } from '../utils/filterByQuery.js';
14
14
  import SearchableList from './SearchableList.js';
15
15
  import { globalSessionOrchestrator } from '../services/globalSessionOrchestrator.js';
16
16
  import { configReader } from '../services/config/configReader.js';
@@ -124,10 +124,9 @@ const Menu = ({ sessionManager, worktreeService, onMenuAction, onSelectRecentPro
124
124
  sortByLastSession: worktreeConfig.sortByLastSession,
125
125
  });
126
126
  const columnPositions = calculateColumnPositions(items);
127
- // Filter worktrees based on search query
128
- const filteredWorktrees = filterWorktreesByQuery(items.map(item => item.worktree), searchQuery);
129
- const filteredWorktreeSet = new Set(filteredWorktrees);
130
- const filteredItems = items.filter(item => filteredWorktreeSet.has(item.worktree));
127
+ // Filter session items based on search query, matching the name shown in
128
+ // the menu (branch name, " (main)", and session name) plus the path.
129
+ const filteredItems = filterSessionItemsByQuery(items, searchQuery);
131
130
  // Build menu items with proper alignment
132
131
  const menuItems = filteredItems.map((item, index) => {
133
132
  const baseLabel = assembleSessionLabel(item, columnPositions);
@@ -1,5 +1,16 @@
1
1
  import { Worktree } from '../types/index.js';
2
+ import { SessionItem } from './worktreeUtils.js';
2
3
  /**
3
4
  * Filter worktrees by matching search query against branch name and path.
4
5
  */
5
6
  export declare function filterWorktreesByQuery(worktrees: Worktree[], query: string): Worktree[];
7
+ /**
8
+ * Filter session items by matching the search query against the name shown in
9
+ * the menu (branch name, " (main)" indicator, and session name) and the
10
+ * worktree path. Status icons and git status columns are not matched.
11
+ *
12
+ * Filtering happens per session item (not per worktree) so that a query can
13
+ * match an individual session name within a worktree that has multiple
14
+ * sessions.
15
+ */
16
+ export declare function filterSessionItemsByQuery(items: SessionItem[], query: string): SessionItem[];
@@ -11,3 +11,19 @@ export function filterWorktreesByQuery(worktrees, query) {
11
11
  worktree.path.toLowerCase().includes(searchLower));
12
12
  });
13
13
  }
14
+ /**
15
+ * Filter session items by matching the search query against the name shown in
16
+ * the menu (branch name, " (main)" indicator, and session name) and the
17
+ * worktree path. Status icons and git status columns are not matched.
18
+ *
19
+ * Filtering happens per session item (not per worktree) so that a query can
20
+ * match an individual session name within a worktree that has multiple
21
+ * sessions.
22
+ */
23
+ export function filterSessionItemsByQuery(items, query) {
24
+ if (!query)
25
+ return items;
26
+ const searchLower = query.toLowerCase();
27
+ return items.filter(item => item.searchableName.toLowerCase().includes(searchLower) ||
28
+ item.worktree.path.toLowerCase().includes(searchLower));
29
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { filterWorktreesByQuery, filterSessionItemsByQuery, } from './filterByQuery.js';
3
+ const makeItem = (searchableName, path) => ({
4
+ worktree: {
5
+ path,
6
+ isMainWorktree: false,
7
+ hasSession: false,
8
+ },
9
+ baseLabel: searchableName,
10
+ searchableName,
11
+ fileChanges: '',
12
+ aheadBehind: '',
13
+ parentBranch: '',
14
+ lastCommitDate: '',
15
+ lengths: {
16
+ base: 0,
17
+ fileChanges: 0,
18
+ aheadBehind: 0,
19
+ parentBranch: 0,
20
+ lastCommitDate: 0,
21
+ },
22
+ });
23
+ describe('filterWorktreesByQuery', () => {
24
+ const worktrees = [
25
+ {
26
+ path: '/repo/feature-a',
27
+ branch: 'feature/a',
28
+ isMainWorktree: false,
29
+ hasSession: false,
30
+ },
31
+ {
32
+ path: '/repo/main',
33
+ branch: 'main',
34
+ isMainWorktree: true,
35
+ hasSession: false,
36
+ },
37
+ ];
38
+ it('returns all worktrees when query is empty', () => {
39
+ expect(filterWorktreesByQuery(worktrees, '')).toEqual(worktrees);
40
+ });
41
+ it('matches branch name case-insensitively', () => {
42
+ const result = filterWorktreesByQuery(worktrees, 'FEATURE');
43
+ expect(result).toHaveLength(1);
44
+ expect(result[0]?.branch).toBe('feature/a');
45
+ });
46
+ it('matches path', () => {
47
+ const result = filterWorktreesByQuery(worktrees, '/repo/main');
48
+ expect(result).toHaveLength(1);
49
+ expect(result[0]?.path).toBe('/repo/main');
50
+ });
51
+ });
52
+ describe('filterSessionItemsByQuery', () => {
53
+ const items = [
54
+ makeItem('feature/a', '/repo/feature-a'),
55
+ makeItem('main (main)', '/repo/main'),
56
+ makeItem('feature/b: my-session', '/repo/feature-b'),
57
+ makeItem('feature/b: other', '/repo/feature-b'),
58
+ ];
59
+ it('returns all items when query is empty', () => {
60
+ expect(filterSessionItemsByQuery(items, '')).toEqual(items);
61
+ });
62
+ it('matches the session name within a worktree', () => {
63
+ const result = filterSessionItemsByQuery(items, 'my-session');
64
+ expect(result).toHaveLength(1);
65
+ expect(result[0]?.searchableName).toBe('feature/b: my-session');
66
+ });
67
+ it('matches the (main) indicator', () => {
68
+ const result = filterSessionItemsByQuery(items, '(main)');
69
+ expect(result).toHaveLength(1);
70
+ expect(result[0]?.searchableName).toBe('main (main)');
71
+ });
72
+ it('matches branch name case-insensitively', () => {
73
+ const result = filterSessionItemsByQuery(items, 'FEATURE/A');
74
+ expect(result).toHaveLength(1);
75
+ expect(result[0]?.searchableName).toBe('feature/a');
76
+ });
77
+ it('matches path', () => {
78
+ const result = filterSessionItemsByQuery(items, '/repo/feature-b');
79
+ expect(result).toHaveLength(2);
80
+ });
81
+ });
@@ -6,6 +6,7 @@ export interface SessionItem {
6
6
  worktree: Worktree;
7
7
  session?: Session;
8
8
  baseLabel: string;
9
+ searchableName: string;
9
10
  fileChanges: string;
10
11
  aheadBehind: string;
11
12
  parentBranch: string;
@@ -24,7 +25,31 @@ export interface SessionItem {
24
25
  */
25
26
  export declare function formatRelativeDate(date: Date): string;
26
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;
27
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
+ };
28
53
  export declare function extractBranchParts(branchName: string): {
29
54
  prefix?: string;
30
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
- // Sanitize branch name for filesystem
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,7 +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 baseLabel = `${branchName}${isMain}${sessionSuffix}${status}`;
216
+ const { displaySuffix: dirSuffix, rawName: rawDirName } = formatWorktreeDirectorySuffix(wt, fullBranchName);
217
+ const baseLabel = `${branchName}${dirSuffix}${isMain}${sessionSuffix}${status}`;
218
+ // Use the full (untruncated) branch name so search still matches the tail
219
+ // of long branch names; status icons are excluded so they don't match.
220
+ const rawDirForSearch = rawDirName ? ` @ ${rawDirName}` : '';
221
+ const searchableName = `${fullBranchName}${rawDirForSearch}${isMain}${sessionSuffix}`;
169
222
  const { fileChanges, aheadBehind, parentBranch, error } = gitStatusColumns(wt, fullBranchName);
170
223
  const lastCommitDate = wt.lastCommitDate
171
224
  ? `\x1b[90m${formatRelativeDate(wt.lastCommitDate)}\x1b[0m`
@@ -174,6 +227,7 @@ function buildSessionItem(wt, session, sessionSuffix) {
174
227
  worktree: wt,
175
228
  session,
176
229
  baseLabel,
230
+ searchableName,
177
231
  fileChanges,
178
232
  aheadBehind,
179
233
  parentBranch,
@@ -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/worktree',
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/worktree',
127
+ worktreePath: '/path/to/test-branch',
126
128
  sessionNumber: 1,
127
129
  command: 'claude',
128
130
  fallbackArgs: undefined,
@@ -167,12 +169,115 @@ 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 = [
173
277
  {
174
278
  worktree: {},
175
279
  baseLabel: 'feature/test-branch',
280
+ searchableName: 'feature/test-branch',
176
281
  fileChanges: '\x1b[32m+10\x1b[0m \x1b[31m-5\x1b[0m',
177
282
  aheadBehind: '\x1b[33m↑2 ↓3\x1b[0m',
178
283
  parentBranch: '',
@@ -188,6 +293,7 @@ describe('column alignment', () => {
188
293
  {
189
294
  worktree: {},
190
295
  baseLabel: 'main',
296
+ searchableName: 'main',
191
297
  fileChanges: '\x1b[32m+2\x1b[0m \x1b[31m-1\x1b[0m',
192
298
  aheadBehind: '\x1b[33m↑1\x1b[0m',
193
299
  parentBranch: '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "4.1.20",
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.20",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.1.20",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.1.20",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.1.20",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.1.20"
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",