ccmanager 4.1.21 → 4.1.23
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/Menu.js +16 -7
- package/dist/services/worktreeNameGenerator.js +18 -0
- package/dist/services/worktreeNameGenerator.test.js +12 -0
- package/dist/utils/filterByQuery.d.ts +25 -0
- package/dist/utils/filterByQuery.js +62 -0
- package/dist/utils/filterByQuery.test.js +66 -1
- package/dist/utils/worktreeUtils.d.ts +24 -0
- package/dist/utils/worktreeUtils.js +58 -8
- package/dist/utils/worktreeUtils.test.js +106 -2
- package/package.json +6 -6
package/dist/components/Menu.js
CHANGED
|
@@ -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 { filterSessionItemsByQuery } from '../utils/filterByQuery.js';
|
|
13
|
+
import { filterSessionItemsByQuery, filterSessionItemsByState, cycleSessionStateFilter, getSessionStateFilterLabel, } 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';
|
|
@@ -40,6 +40,7 @@ const Menu = ({ sessionManager, worktreeService, onMenuAction, onSelectRecentPro
|
|
|
40
40
|
const [highlightedWorktreePath, setHighlightedWorktreePath] = useState(null);
|
|
41
41
|
const [highlightedSession, setHighlightedSession] = useState(undefined);
|
|
42
42
|
const [autoApprovalToggleCounter, setAutoApprovalToggleCounter] = useState(0);
|
|
43
|
+
const [stateFilter, setStateFilter] = useState('all');
|
|
43
44
|
// Use the search mode hook
|
|
44
45
|
const { isSearchMode, searchQuery, selectedIndex, setSearchQuery } = useSearchMode(items.length, {
|
|
45
46
|
isDisabled: !!error || !!loadError,
|
|
@@ -125,8 +126,10 @@ const Menu = ({ sessionManager, worktreeService, onMenuAction, onSelectRecentPro
|
|
|
125
126
|
});
|
|
126
127
|
const columnPositions = calculateColumnPositions(items);
|
|
127
128
|
// 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
|
-
|
|
129
|
+
// the menu (branch name, " (main)", and session name) plus the path, then
|
|
130
|
+
// narrow to the selected session state. The two filters are independent
|
|
131
|
+
// dimensions and compose: both can be active at once.
|
|
132
|
+
const filteredItems = filterSessionItemsByState(filterSessionItemsByQuery(items, searchQuery), stateFilter);
|
|
130
133
|
// Build menu items with proper alignment
|
|
131
134
|
const menuItems = filteredItems.map((item, index) => {
|
|
132
135
|
const baseLabel = assembleSessionLabel(item, columnPositions);
|
|
@@ -279,12 +282,13 @@ const Menu = ({ sessionManager, worktreeService, onMenuAction, onSelectRecentPro
|
|
|
279
282
|
recentProjects,
|
|
280
283
|
searchQuery,
|
|
281
284
|
isSearchMode,
|
|
285
|
+
stateFilter,
|
|
282
286
|
autoApprovalToggleCounter,
|
|
283
287
|
sessionManager,
|
|
284
288
|
worktreeConfig.sortByLastSession,
|
|
285
289
|
]);
|
|
286
290
|
// Handle hotkeys
|
|
287
|
-
useInput((input,
|
|
291
|
+
useInput((input, key) => {
|
|
288
292
|
// Skip in test environment to avoid stdin.ref error
|
|
289
293
|
if (!process.stdin.setRawMode) {
|
|
290
294
|
return;
|
|
@@ -303,6 +307,11 @@ const Menu = ({ sessionManager, worktreeService, onMenuAction, onSelectRecentPro
|
|
|
303
307
|
if (isSearchMode) {
|
|
304
308
|
return;
|
|
305
309
|
}
|
|
310
|
+
// Cycle the session-state filter: Tab forward, Shift+Tab backward.
|
|
311
|
+
if (key.tab) {
|
|
312
|
+
setStateFilter(prev => cycleSessionStateFilter(prev, key.shift ? 'prev' : 'next'));
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
306
315
|
const keyPressed = input.toLowerCase();
|
|
307
316
|
// Handle number keys 0-9 for worktree selection
|
|
308
317
|
if (/^[0-9]$/.test(keyPressed)) {
|
|
@@ -429,7 +438,7 @@ const Menu = ({ sessionManager, worktreeService, onMenuAction, onSelectRecentPro
|
|
|
429
438
|
});
|
|
430
439
|
}
|
|
431
440
|
};
|
|
432
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "green", children: ["CCManager - Claude Code Worktree Manager v", version] }), projectName && (_jsx(Text, { bold: true, color: "green", children: projectName }))] }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Select a worktree to start or resume a Claude Code session:" }) }), _jsx(SearchableList, { isSearchMode: isSearchMode, searchQuery: searchQuery, onSearchQueryChange: setSearchQuery, selectedIndex: selectedIndex, items: items, limit: limit, placeholder: "Type to filter worktrees...", noMatchMessage: "No worktrees match your search", children: _jsx(SelectInput, { items: items, onSelect: raw => {
|
|
441
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "green", children: ["CCManager - Claude Code Worktree Manager v", version] }), projectName && (_jsx(Text, { bold: true, color: "green", children: projectName }))] }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Select a worktree to start or resume a Claude Code session:" }) }), ((searchQuery && !isSearchMode) || stateFilter !== 'all') && (_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [searchQuery && !isSearchMode && (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Filtered: " }), _jsxs(Text, { color: "cyan", bold: true, children: ["\"", searchQuery, "\""] })] })), stateFilter !== 'all' && (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "State filter: " }), _jsx(Text, { color: "cyan", bold: true, children: getSessionStateFilterLabel(stateFilter) }), _jsx(Text, { dimColor: true, children: " (Tab to cycle, back to All clears it)" })] }))] })), _jsx(SearchableList, { isSearchMode: isSearchMode, searchQuery: searchQuery, onSearchQueryChange: setSearchQuery, selectedIndex: selectedIndex, items: items, limit: limit, placeholder: "Type to filter worktrees...", noMatchMessage: "No worktrees match your search", children: _jsx(SelectInput, { items: items, onSelect: raw => {
|
|
433
442
|
const item = items.find(i => i.value === raw?.value);
|
|
434
443
|
if (!item)
|
|
435
444
|
return;
|
|
@@ -445,7 +454,7 @@ const Menu = ({ sessionManager, worktreeService, onMenuAction, onSelectRecentPro
|
|
|
445
454
|
}, isFocused: !error, initialIndex: selectedIndex, limit: limit }) }), (error || loadError) && (_jsx(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "red", children: _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "red", bold: true, children: ["Error: ", error || loadError] }), _jsx(Text, { color: "gray", dimColor: true, children: "Press any key to dismiss" })] }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Status: ", STATUS_ICONS.BUSY, " ", STATUS_LABELS.BUSY, ' ', STATUS_ICONS.WAITING, " ", STATUS_LABELS.WAITING, " ", STATUS_ICONS.IDLE, ' ', STATUS_LABELS.IDLE, configReader.isAutoApprovalEnabled() && (_jsxs(_Fragment, { children: [' | ', _jsx(Text, { color: "green", children: "Auto Approval Enabled" })] }))] }), _jsx(Text, { dimColor: true, children: isSearchMode
|
|
446
455
|
? 'Search Mode: Type to filter, Enter to exit search, ESC to exit search'
|
|
447
456
|
: searchQuery
|
|
448
|
-
? `
|
|
449
|
-
: `Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search Space-Session actions (session rows only) N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}` })] })] }));
|
|
457
|
+
? `Controls: ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select Tab-State Filter Space-Session actions (session rows only) N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}`
|
|
458
|
+
: `Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search Tab-State Filter Space-Session actions (session rows only) N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}` })] })] }));
|
|
450
459
|
};
|
|
451
460
|
export default Menu;
|
|
@@ -12,6 +12,14 @@ const JSON_SCHEMA = JSON.stringify({
|
|
|
12
12
|
additionalProperties: false,
|
|
13
13
|
});
|
|
14
14
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
15
|
+
// Generated branch names are short by construction. When `claude -p` returns
|
|
16
|
+
// prose instead of a name (e.g. a refusal, or a request for an issue's
|
|
17
|
+
// contents it cannot fetch), normalizing that text yields an unusually long,
|
|
18
|
+
// many-segment slug. These bounds let us detect that case and reject it so the
|
|
19
|
+
// caller falls back to a generated name instead of branching off a whole
|
|
20
|
+
// sentence. They are also surfaced to the model in the prompt.
|
|
21
|
+
const MAX_GENERATED_BRANCH_NAME_LENGTH = 60;
|
|
22
|
+
const MAX_GENERATED_BRANCH_NAME_SEGMENTS = 10;
|
|
15
23
|
const buildPrompt = (userPrompt, baseBranch) => `You generate concise git branch names.
|
|
16
24
|
|
|
17
25
|
Base branch: ${baseBranch}
|
|
@@ -19,7 +27,10 @@ Task prompt:
|
|
|
19
27
|
${userPrompt}
|
|
20
28
|
|
|
21
29
|
Return a short git branch name using lowercase letters, numbers, hyphens, and forward slashes only.
|
|
30
|
+
Use at most ${MAX_GENERATED_BRANCH_NAME_SEGMENTS} hyphen- or slash-separated words and keep it under ${MAX_GENERATED_BRANCH_NAME_LENGTH} characters.
|
|
22
31
|
Do not include markdown, explanations, refs/heads/, or surrounding quotes.
|
|
32
|
+
Always output a branch name. Never refuse, never ask for clarification, and never request more information or access.
|
|
33
|
+
If the task prompt references context you cannot access (for example an issue number, ticket, or URL), do not mention that limitation: just produce a best-effort name from the words available in the task prompt itself.
|
|
23
34
|
Examples: feature/add-prompt-mode, fix/worktree-loading-state`;
|
|
24
35
|
const normalizeBranchName = (branchName) => {
|
|
25
36
|
const normalized = branchName
|
|
@@ -38,6 +49,13 @@ const normalizeBranchName = (branchName) => {
|
|
|
38
49
|
if (!normalized) {
|
|
39
50
|
throw new Error('Generated branch name was empty');
|
|
40
51
|
}
|
|
52
|
+
if (normalized.length > MAX_GENERATED_BRANCH_NAME_LENGTH) {
|
|
53
|
+
throw new Error(`Generated branch name was too long (${normalized.length} > ${MAX_GENERATED_BRANCH_NAME_LENGTH} chars); treating it as invalid output`);
|
|
54
|
+
}
|
|
55
|
+
const segmentCount = normalized.split(/[/-]/).filter(Boolean).length;
|
|
56
|
+
if (segmentCount > MAX_GENERATED_BRANCH_NAME_SEGMENTS) {
|
|
57
|
+
throw new Error(`Generated branch name had too many words (${segmentCount} > ${MAX_GENERATED_BRANCH_NAME_SEGMENTS}); treating it as invalid output`);
|
|
58
|
+
}
|
|
41
59
|
return normalized;
|
|
42
60
|
};
|
|
43
61
|
const extractStringCandidate = (value) => {
|
|
@@ -18,6 +18,18 @@ describe('WorktreeNameGenerator output parsing', () => {
|
|
|
18
18
|
const value = extractBranchNameFromOutput('type":"result","subtype":"success","structured_output":{"branchName":"fix/trim-worktree-name"},"uuid":"123"');
|
|
19
19
|
expect(value).toBe('fix/trim-worktree-name');
|
|
20
20
|
});
|
|
21
|
+
it('rejects prose refusals that overflow the length budget', () => {
|
|
22
|
+
// `claude -p` sometimes puts a refusal sentence into the branchName field
|
|
23
|
+
// instead of a name; the caller relies on this throwing to use a fallback.
|
|
24
|
+
expect(() => extractBranchNameFromOutput('{"branchName":"i can t generate an informed branch name without understanding issue 310 could you either grant permission for webfetch or paste the issue description"}')).toThrow();
|
|
25
|
+
});
|
|
26
|
+
it('rejects names with too many words even when short', () => {
|
|
27
|
+
expect(() => extractBranchNameFromOutput('{"branchName":"a-b-c-d-e-f-g-h-i-j-k-l"}')).toThrow();
|
|
28
|
+
});
|
|
29
|
+
it('accepts a normal multi-word branch name', () => {
|
|
30
|
+
const value = extractBranchNameFromOutput('{"branchName":"feature/add-prompt-mode-toggle"}');
|
|
31
|
+
expect(value).toBe('feature/add-prompt-mode-toggle');
|
|
32
|
+
});
|
|
21
33
|
});
|
|
22
34
|
describe('deduplicateBranchName', () => {
|
|
23
35
|
it('returns the name unchanged when no conflict exists', () => {
|
|
@@ -14,3 +14,28 @@ export declare function filterWorktreesByQuery(worktrees: Worktree[], query: str
|
|
|
14
14
|
* sessions.
|
|
15
15
|
*/
|
|
16
16
|
export declare function filterSessionItemsByQuery(items: SessionItem[], query: string): SessionItem[];
|
|
17
|
+
/**
|
|
18
|
+
* The user-facing categories a session can be filtered by. `'all'` disables the
|
|
19
|
+
* filter. The four internal {@link SessionState} values collapse into three
|
|
20
|
+
* categories: `pending_auto_approval` is shown as `waiting` because it is a
|
|
21
|
+
* form of "waiting for the user" (mirrors the status display in statusIcons.ts).
|
|
22
|
+
*/
|
|
23
|
+
export type SessionStateFilter = 'all' | 'busy' | 'waiting' | 'idle';
|
|
24
|
+
/**
|
|
25
|
+
* Order in which the Tab / Shift+Tab keys cycle the state filter.
|
|
26
|
+
* "Attention-first" so the states a user most often hunts for come up first.
|
|
27
|
+
*/
|
|
28
|
+
export declare const SESSION_STATE_FILTER_CYCLE: SessionStateFilter[];
|
|
29
|
+
/**
|
|
30
|
+
* Filter session items by their current state. This is an independent dimension
|
|
31
|
+
* from {@link filterSessionItemsByQuery}: callers compose the two so a text
|
|
32
|
+
* query and a state filter can both be active at once.
|
|
33
|
+
*
|
|
34
|
+
* Rows without a session have no state, so they are excluded whenever a specific
|
|
35
|
+
* state (not `'all'`) is selected.
|
|
36
|
+
*/
|
|
37
|
+
export declare function filterSessionItemsByState(items: SessionItem[], filter: SessionStateFilter): SessionItem[];
|
|
38
|
+
/** Advance the state filter one step in the cycle (Tab) or back (Shift+Tab). */
|
|
39
|
+
export declare function cycleSessionStateFilter(current: SessionStateFilter, direction: 'next' | 'prev'): SessionStateFilter;
|
|
40
|
+
/** Human-readable label (icon + word) for a state filter, used in the footer. */
|
|
41
|
+
export declare function getSessionStateFilterLabel(filter: SessionStateFilter): string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { STATUS_ICONS, STATUS_LABELS } from '../constants/statusIcons.js';
|
|
1
2
|
/**
|
|
2
3
|
* Filter worktrees by matching search query against branch name and path.
|
|
3
4
|
*/
|
|
@@ -27,3 +28,64 @@ export function filterSessionItemsByQuery(items, query) {
|
|
|
27
28
|
return items.filter(item => item.searchableName.toLowerCase().includes(searchLower) ||
|
|
28
29
|
item.worktree.path.toLowerCase().includes(searchLower));
|
|
29
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Order in which the Tab / Shift+Tab keys cycle the state filter.
|
|
33
|
+
* "Attention-first" so the states a user most often hunts for come up first.
|
|
34
|
+
*/
|
|
35
|
+
export const SESSION_STATE_FILTER_CYCLE = [
|
|
36
|
+
'all',
|
|
37
|
+
'busy',
|
|
38
|
+
'waiting',
|
|
39
|
+
'idle',
|
|
40
|
+
];
|
|
41
|
+
/** Map an internal session state onto its user-facing filter category. */
|
|
42
|
+
function stateToFilterCategory(state) {
|
|
43
|
+
switch (state) {
|
|
44
|
+
case 'busy':
|
|
45
|
+
return 'busy';
|
|
46
|
+
case 'waiting_input':
|
|
47
|
+
case 'pending_auto_approval':
|
|
48
|
+
return 'waiting';
|
|
49
|
+
case 'idle':
|
|
50
|
+
return 'idle';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Filter session items by their current state. This is an independent dimension
|
|
55
|
+
* from {@link filterSessionItemsByQuery}: callers compose the two so a text
|
|
56
|
+
* query and a state filter can both be active at once.
|
|
57
|
+
*
|
|
58
|
+
* Rows without a session have no state, so they are excluded whenever a specific
|
|
59
|
+
* state (not `'all'`) is selected.
|
|
60
|
+
*/
|
|
61
|
+
export function filterSessionItemsByState(items, filter) {
|
|
62
|
+
if (filter === 'all')
|
|
63
|
+
return items;
|
|
64
|
+
return items.filter(item => {
|
|
65
|
+
const stateData = item.session?.stateMutex.getSnapshot();
|
|
66
|
+
if (!stateData)
|
|
67
|
+
return false;
|
|
68
|
+
return stateToFilterCategory(stateData.state) === filter;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/** Advance the state filter one step in the cycle (Tab) or back (Shift+Tab). */
|
|
72
|
+
export function cycleSessionStateFilter(current, direction) {
|
|
73
|
+
const cycle = SESSION_STATE_FILTER_CYCLE;
|
|
74
|
+
const index = cycle.indexOf(current);
|
|
75
|
+
const offset = direction === 'next' ? 1 : -1;
|
|
76
|
+
const nextIndex = (index + offset + cycle.length) % cycle.length;
|
|
77
|
+
return cycle[nextIndex];
|
|
78
|
+
}
|
|
79
|
+
/** Human-readable label (icon + word) for a state filter, used in the footer. */
|
|
80
|
+
export function getSessionStateFilterLabel(filter) {
|
|
81
|
+
switch (filter) {
|
|
82
|
+
case 'all':
|
|
83
|
+
return 'All';
|
|
84
|
+
case 'busy':
|
|
85
|
+
return `${STATUS_ICONS.BUSY} ${STATUS_LABELS.BUSY}`;
|
|
86
|
+
case 'waiting':
|
|
87
|
+
return `${STATUS_ICONS.WAITING} ${STATUS_LABELS.WAITING}`;
|
|
88
|
+
case 'idle':
|
|
89
|
+
return `${STATUS_ICONS.IDLE} ${STATUS_LABELS.IDLE}`;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { filterWorktreesByQuery, filterSessionItemsByQuery, } from './filterByQuery.js';
|
|
2
|
+
import { filterWorktreesByQuery, filterSessionItemsByQuery, filterSessionItemsByState, cycleSessionStateFilter, getSessionStateFilterLabel, } from './filterByQuery.js';
|
|
3
|
+
import { STATUS_ICONS, STATUS_LABELS } from '../constants/statusIcons.js';
|
|
3
4
|
const makeItem = (searchableName, path) => ({
|
|
4
5
|
worktree: {
|
|
5
6
|
path,
|
|
@@ -20,6 +21,14 @@ const makeItem = (searchableName, path) => ({
|
|
|
20
21
|
lastCommitDate: 0,
|
|
21
22
|
},
|
|
22
23
|
});
|
|
24
|
+
// Minimal session stub exposing only what filterSessionItemsByState reads:
|
|
25
|
+
// stateMutex.getSnapshot().state.
|
|
26
|
+
const makeItemWithState = (searchableName, path, state) => ({
|
|
27
|
+
...makeItem(searchableName, path),
|
|
28
|
+
session: {
|
|
29
|
+
stateMutex: { getSnapshot: () => ({ state }) },
|
|
30
|
+
},
|
|
31
|
+
});
|
|
23
32
|
describe('filterWorktreesByQuery', () => {
|
|
24
33
|
const worktrees = [
|
|
25
34
|
{
|
|
@@ -79,3 +88,59 @@ describe('filterSessionItemsByQuery', () => {
|
|
|
79
88
|
expect(result).toHaveLength(2);
|
|
80
89
|
});
|
|
81
90
|
});
|
|
91
|
+
describe('filterSessionItemsByState', () => {
|
|
92
|
+
const items = [
|
|
93
|
+
makeItemWithState('busy-a', '/repo/busy-a', 'busy'),
|
|
94
|
+
makeItemWithState('waiting-a', '/repo/waiting-a', 'waiting_input'),
|
|
95
|
+
makeItemWithState('pending-a', '/repo/pending-a', 'pending_auto_approval'),
|
|
96
|
+
makeItemWithState('idle-a', '/repo/idle-a', 'idle'),
|
|
97
|
+
// A worktree row with no running session (no state).
|
|
98
|
+
makeItem('no-session', '/repo/no-session'),
|
|
99
|
+
];
|
|
100
|
+
it('returns all items when the filter is "all"', () => {
|
|
101
|
+
expect(filterSessionItemsByState(items, 'all')).toEqual(items);
|
|
102
|
+
});
|
|
103
|
+
it('keeps only busy sessions', () => {
|
|
104
|
+
const result = filterSessionItemsByState(items, 'busy');
|
|
105
|
+
expect(result.map(i => i.searchableName)).toEqual(['busy-a']);
|
|
106
|
+
});
|
|
107
|
+
it('folds pending_auto_approval into the waiting category', () => {
|
|
108
|
+
const result = filterSessionItemsByState(items, 'waiting');
|
|
109
|
+
expect(result.map(i => i.searchableName)).toEqual([
|
|
110
|
+
'waiting-a',
|
|
111
|
+
'pending-a',
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
it('keeps only idle sessions', () => {
|
|
115
|
+
const result = filterSessionItemsByState(items, 'idle');
|
|
116
|
+
expect(result.map(i => i.searchableName)).toEqual(['idle-a']);
|
|
117
|
+
});
|
|
118
|
+
it('excludes rows without a session for any specific state', () => {
|
|
119
|
+
for (const filter of ['busy', 'waiting', 'idle']) {
|
|
120
|
+
const result = filterSessionItemsByState(items, filter);
|
|
121
|
+
expect(result.map(i => i.searchableName)).not.toContain('no-session');
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
describe('cycleSessionStateFilter', () => {
|
|
126
|
+
it('cycles forward all -> busy -> waiting -> idle -> all', () => {
|
|
127
|
+
expect(cycleSessionStateFilter('all', 'next')).toBe('busy');
|
|
128
|
+
expect(cycleSessionStateFilter('busy', 'next')).toBe('waiting');
|
|
129
|
+
expect(cycleSessionStateFilter('waiting', 'next')).toBe('idle');
|
|
130
|
+
expect(cycleSessionStateFilter('idle', 'next')).toBe('all');
|
|
131
|
+
});
|
|
132
|
+
it('cycles backward all -> idle -> waiting -> busy -> all', () => {
|
|
133
|
+
expect(cycleSessionStateFilter('all', 'prev')).toBe('idle');
|
|
134
|
+
expect(cycleSessionStateFilter('idle', 'prev')).toBe('waiting');
|
|
135
|
+
expect(cycleSessionStateFilter('waiting', 'prev')).toBe('busy');
|
|
136
|
+
expect(cycleSessionStateFilter('busy', 'prev')).toBe('all');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
describe('getSessionStateFilterLabel', () => {
|
|
140
|
+
it('labels each filter with icon and word', () => {
|
|
141
|
+
expect(getSessionStateFilterLabel('all')).toBe('All');
|
|
142
|
+
expect(getSessionStateFilterLabel('busy')).toBe(`${STATUS_ICONS.BUSY} ${STATUS_LABELS.BUSY}`);
|
|
143
|
+
expect(getSessionStateFilterLabel('waiting')).toBe(`${STATUS_ICONS.WAITING} ${STATUS_LABELS.WAITING}`);
|
|
144
|
+
expect(getSessionStateFilterLabel('idle')).toBe(`${STATUS_ICONS.IDLE} ${STATUS_LABELS.IDLE}`);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -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.23",
|
|
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.23",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "4.1.23",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "4.1.23",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "4.1.23",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "4.1.23"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|