ccmanager 4.3.2 → 4.4.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/README.md +42 -0
- package/dist/components/App.js +46 -21
- package/dist/components/App.test.js +86 -0
- package/dist/components/Dashboard.js +10 -3
- package/dist/components/Menu.js +6 -1
- package/dist/components/RestoreSessions.d.ts +19 -0
- package/dist/components/RestoreSessions.js +28 -0
- package/dist/hooks/useAvailableLabelWidth.d.ts +6 -0
- package/dist/hooks/useAvailableLabelWidth.js +15 -0
- package/dist/services/config/globalConfigManager.js +3 -12
- package/dist/services/globalSessionOrchestrator.d.ts +7 -0
- package/dist/services/globalSessionOrchestrator.js +40 -0
- package/dist/services/globalSessionOrchestrator.restore.test.d.ts +1 -0
- package/dist/services/globalSessionOrchestrator.restore.test.js +76 -0
- package/dist/services/projectManager.js +3 -11
- package/dist/services/sessionManager.d.ts +6 -0
- package/dist/services/sessionManager.js +16 -0
- package/dist/services/sessionRestoreStore.d.ts +75 -0
- package/dist/services/sessionRestoreStore.js +138 -0
- package/dist/services/sessionRestoreStore.test.d.ts +1 -0
- package/dist/services/sessionRestoreStore.test.js +92 -0
- package/dist/services/sessionRestorer.d.ts +46 -0
- package/dist/services/sessionRestorer.js +123 -0
- package/dist/services/sessionRestorer.test.d.ts +1 -0
- package/dist/services/sessionRestorer.test.js +163 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/utils/configDir.d.ts +10 -0
- package/dist/utils/configDir.js +31 -0
- package/dist/utils/errorMessage.d.ts +7 -0
- package/dist/utils/errorMessage.js +19 -0
- package/dist/utils/filterByQuery.test.js +2 -0
- package/dist/utils/gitUtils.d.ts +1 -0
- package/dist/utils/gitUtils.js +15 -0
- package/dist/utils/hookExecutor.test.js +4 -0
- package/dist/utils/worktreeUtils.d.ts +25 -4
- package/dist/utils/worktreeUtils.js +53 -17
- package/dist/utils/worktreeUtils.test.js +62 -4
- package/package.json +6 -6
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { Effect, Either } from 'effect';
|
|
3
|
+
import { mkdtempSync, mkdirSync } from 'fs';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { ProcessError, ValidationError } from '../types/errors.js';
|
|
7
|
+
const storedRecords = [];
|
|
8
|
+
const forgetMock = vi.fn((...ids) => {
|
|
9
|
+
for (const id of ids) {
|
|
10
|
+
const index = storedRecords.findIndex(record => record.id === id);
|
|
11
|
+
if (index !== -1) {
|
|
12
|
+
storedRecords.splice(index, 1);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
vi.mock('./sessionRestoreStore.js', () => ({
|
|
17
|
+
sessionRestoreStore: {
|
|
18
|
+
list: () => [...storedRecords],
|
|
19
|
+
forget: (...ids) => forgetMock(...ids),
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
class MockSessionManager {
|
|
23
|
+
createSessionWithPresetEffect = vi.fn((_worktreePath, _presetId) => Effect.succeed({ id: 'new-session' }));
|
|
24
|
+
createSessionWithDevcontainerEffect = vi.fn((_worktreePath, _devcontainerConfig, _presetId) => Effect.succeed({ id: 'new-session' }));
|
|
25
|
+
renameSession = vi.fn((_id, _name) => { });
|
|
26
|
+
}
|
|
27
|
+
const managersByProject = new Map();
|
|
28
|
+
const getManagerForProjectMock = vi.fn((projectPath) => {
|
|
29
|
+
let manager = managersByProject.get(projectPath);
|
|
30
|
+
if (!manager) {
|
|
31
|
+
manager = new MockSessionManager();
|
|
32
|
+
managersByProject.set(projectPath, manager);
|
|
33
|
+
}
|
|
34
|
+
return manager;
|
|
35
|
+
});
|
|
36
|
+
vi.mock('./globalSessionOrchestrator.js', () => ({
|
|
37
|
+
globalSessionOrchestrator: {
|
|
38
|
+
getManagerForProject: (projectPath) => getManagerForProjectMock(projectPath),
|
|
39
|
+
},
|
|
40
|
+
}));
|
|
41
|
+
vi.mock('./config/configReader.js', () => ({
|
|
42
|
+
configReader: {
|
|
43
|
+
getPresetByIdEffect: (id) => id === 'preset-1'
|
|
44
|
+
? Either.right({ id: 'preset-1', name: 'Main' })
|
|
45
|
+
: Either.left(new ValidationError({
|
|
46
|
+
field: 'presetId',
|
|
47
|
+
constraint: 'Preset not found',
|
|
48
|
+
receivedValue: id,
|
|
49
|
+
})),
|
|
50
|
+
getDefaultPreset: () => ({ id: 'default', name: 'Default' }),
|
|
51
|
+
},
|
|
52
|
+
}));
|
|
53
|
+
const { listRestorableSessions, restoreSessions, discardRestorableSessions, describeRecordPreset, } = await import('./sessionRestorer.js');
|
|
54
|
+
describe('sessionRestorer', () => {
|
|
55
|
+
let existingWorktree;
|
|
56
|
+
const record = (overrides = {}) => ({
|
|
57
|
+
id: 'session-1',
|
|
58
|
+
projectPath: '/repo',
|
|
59
|
+
worktreePath: existingWorktree,
|
|
60
|
+
presetId: 'preset-1',
|
|
61
|
+
// A process id that is not running, so the record counts as restorable.
|
|
62
|
+
ownerPid: 999999,
|
|
63
|
+
createdAt: 1,
|
|
64
|
+
...overrides,
|
|
65
|
+
});
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
const directory = mkdtempSync(path.join(tmpdir(), 'ccmanager-restore-'));
|
|
68
|
+
existingWorktree = path.join(directory, 'feature');
|
|
69
|
+
mkdirSync(existingWorktree);
|
|
70
|
+
storedRecords.length = 0;
|
|
71
|
+
forgetMock.mockClear();
|
|
72
|
+
managersByProject.clear();
|
|
73
|
+
getManagerForProjectMock.mockClear();
|
|
74
|
+
});
|
|
75
|
+
describe('listRestorableSessions', () => {
|
|
76
|
+
it('offers a recorded session whose worktree still exists', () => {
|
|
77
|
+
storedRecords.push(record());
|
|
78
|
+
expect(listRestorableSessions().map(r => r.id)).toEqual(['session-1']);
|
|
79
|
+
});
|
|
80
|
+
it('skips a session whose worktree has been deleted', () => {
|
|
81
|
+
storedRecords.push(record({ worktreePath: '/gone/worktree' }));
|
|
82
|
+
expect(listRestorableSessions()).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
it('skips a session still owned by a running ccmanager', () => {
|
|
85
|
+
storedRecords.push(record({ ownerPid: process.pid }));
|
|
86
|
+
expect(listRestorableSessions()).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
it('only offers the requested project when a project path is given', () => {
|
|
89
|
+
storedRecords.push(record({ id: 'mine', projectPath: '/repo' }));
|
|
90
|
+
storedRecords.push(record({ id: 'other', projectPath: '/elsewhere' }));
|
|
91
|
+
expect(listRestorableSessions({ projectPath: '/repo' }).map(r => r.id)).toEqual(['mine']);
|
|
92
|
+
});
|
|
93
|
+
it('returns oldest first so sessions come back in the order they were opened', () => {
|
|
94
|
+
storedRecords.push(record({ id: 'second', createdAt: 20 }));
|
|
95
|
+
storedRecords.push(record({ id: 'first', createdAt: 10 }));
|
|
96
|
+
expect(listRestorableSessions().map(r => r.id)).toEqual([
|
|
97
|
+
'first',
|
|
98
|
+
'second',
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe('restoreSessions', () => {
|
|
103
|
+
it('launches the recorded preset in the recorded worktree', async () => {
|
|
104
|
+
const target = record();
|
|
105
|
+
const outcome = await restoreSessions([target], { multiProject: false });
|
|
106
|
+
const manager = managersByProject.get(undefined);
|
|
107
|
+
expect(manager.createSessionWithPresetEffect).toHaveBeenCalledWith(existingWorktree, 'preset-1');
|
|
108
|
+
expect(outcome).toEqual({ restored: 1, failures: [] });
|
|
109
|
+
});
|
|
110
|
+
it('drops the old record so the same session is not offered twice', async () => {
|
|
111
|
+
storedRecords.push(record());
|
|
112
|
+
await restoreSessions([record()], { multiProject: false });
|
|
113
|
+
expect(forgetMock).toHaveBeenCalledWith('session-1');
|
|
114
|
+
expect(storedRecords).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
it('restores the session name the user had given', async () => {
|
|
117
|
+
await restoreSessions([record({ sessionName: 'review' })], {
|
|
118
|
+
multiProject: false,
|
|
119
|
+
});
|
|
120
|
+
const manager = managersByProject.get(undefined);
|
|
121
|
+
expect(manager.renameSession).toHaveBeenCalledWith('new-session', 'review');
|
|
122
|
+
});
|
|
123
|
+
it('uses the manager of the recorded project in multi-project mode', async () => {
|
|
124
|
+
await restoreSessions([record()], { multiProject: true });
|
|
125
|
+
expect(getManagerForProjectMock).toHaveBeenCalledWith('/repo');
|
|
126
|
+
});
|
|
127
|
+
it('relaunches through the devcontainer when the session used one', async () => {
|
|
128
|
+
const devcontainerConfig = {
|
|
129
|
+
upCommand: 'devcontainer up',
|
|
130
|
+
execCommand: 'devcontainer exec',
|
|
131
|
+
};
|
|
132
|
+
await restoreSessions([record({ devcontainerConfig })], {
|
|
133
|
+
multiProject: false,
|
|
134
|
+
});
|
|
135
|
+
const manager = managersByProject.get(undefined);
|
|
136
|
+
expect(manager.createSessionWithDevcontainerEffect).toHaveBeenCalledWith(existingWorktree, devcontainerConfig, 'preset-1');
|
|
137
|
+
});
|
|
138
|
+
it('reports a failed session and carries on with the rest', async () => {
|
|
139
|
+
const failing = record({ id: 'failing' });
|
|
140
|
+
const succeeding = record({ id: 'succeeding' });
|
|
141
|
+
const manager = getManagerForProjectMock(undefined);
|
|
142
|
+
manager.createSessionWithPresetEffect.mockImplementationOnce(() => Effect.fail(new ProcessError({ command: 'claude', message: 'spawn failed' })));
|
|
143
|
+
const outcome = await restoreSessions([failing, succeeding], {
|
|
144
|
+
multiProject: false,
|
|
145
|
+
});
|
|
146
|
+
expect(outcome.restored).toBe(1);
|
|
147
|
+
expect(outcome.failures).toHaveLength(1);
|
|
148
|
+
expect(outcome.failures[0]?.record.id).toBe('failing');
|
|
149
|
+
expect(outcome.failures[0]?.message).toContain('spawn failed');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
it('describes a record by its current preset name, falling back to the default', () => {
|
|
153
|
+
expect(describeRecordPreset(record())).toBe('Main');
|
|
154
|
+
expect(describeRecordPreset(record({ presetId: 'removed' }))).toBe('Default');
|
|
155
|
+
expect(describeRecordPreset(record({ presetId: undefined }))).toBe('Default');
|
|
156
|
+
});
|
|
157
|
+
it('forgets the sessions the user declined to restore', () => {
|
|
158
|
+
storedRecords.push(record({ id: 'a' }), record({ id: 'b' }));
|
|
159
|
+
discardRestorableSessions([record({ id: 'a' }), record({ id: 'b' })]);
|
|
160
|
+
expect(forgetMock).toHaveBeenCalledWith('a', 'b');
|
|
161
|
+
expect(storedRecords).toEqual([]);
|
|
162
|
+
});
|
|
163
|
+
});
|
package/dist/types/index.d.ts
CHANGED
|
@@ -40,6 +40,7 @@ export interface Session {
|
|
|
40
40
|
stateCheckInterval: NodeJS.Timeout | undefined;
|
|
41
41
|
isPrimaryCommand: boolean;
|
|
42
42
|
presetName: string | undefined;
|
|
43
|
+
presetId: string | undefined;
|
|
43
44
|
detectionStrategy: StateDetectionStrategy | undefined;
|
|
44
45
|
devcontainerConfig: DevcontainerConfig | undefined;
|
|
45
46
|
/**
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path of ccmanager's configuration/state directory. Does not touch the
|
|
3
|
+
* filesystem.
|
|
4
|
+
*/
|
|
5
|
+
export declare function getConfigDir(): string;
|
|
6
|
+
/**
|
|
7
|
+
* Path of ccmanager's configuration/state directory, creating it first if it
|
|
8
|
+
* does not exist yet.
|
|
9
|
+
*/
|
|
10
|
+
export declare function ensureConfigDir(): string;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Single source of truth for the location of ccmanager's own
|
|
3
|
+
* configuration/state directory (`~/.config/ccmanager`, or the Windows
|
|
4
|
+
* equivalent under APPDATA). Every module that needs to read or write a file
|
|
5
|
+
* belonging to ccmanager itself resolves the directory through here so the
|
|
6
|
+
* location is defined in exactly one place.
|
|
7
|
+
*/
|
|
8
|
+
import { homedir } from 'os';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { existsSync, mkdirSync } from 'fs';
|
|
11
|
+
/**
|
|
12
|
+
* Path of ccmanager's configuration/state directory. Does not touch the
|
|
13
|
+
* filesystem.
|
|
14
|
+
*/
|
|
15
|
+
export function getConfigDir() {
|
|
16
|
+
const homeDir = homedir();
|
|
17
|
+
return process.platform === 'win32'
|
|
18
|
+
? path.join(process.env['APPDATA'] || path.join(homeDir, 'AppData', 'Roaming'), 'ccmanager')
|
|
19
|
+
: path.join(homeDir, '.config', 'ccmanager');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Path of ccmanager's configuration/state directory, creating it first if it
|
|
23
|
+
* does not exist yet.
|
|
24
|
+
*/
|
|
25
|
+
export function ensureConfigDir() {
|
|
26
|
+
const configDir = getConfigDir();
|
|
27
|
+
if (!existsSync(configDir)) {
|
|
28
|
+
mkdirSync(configDir, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
return configDir;
|
|
31
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type AppError } from '../types/errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Human-readable one-line rendering of an application error, for display in the
|
|
4
|
+
* TUI and for log lines. Each error type carries different fields, so the text
|
|
5
|
+
* is composed per type via the `_tag` discriminator.
|
|
6
|
+
*/
|
|
7
|
+
export declare function formatErrorMessage(error: AppError): string;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable one-line rendering of an application error, for display in the
|
|
3
|
+
* TUI and for log lines. Each error type carries different fields, so the text
|
|
4
|
+
* is composed per type via the `_tag` discriminator.
|
|
5
|
+
*/
|
|
6
|
+
export function formatErrorMessage(error) {
|
|
7
|
+
switch (error._tag) {
|
|
8
|
+
case 'ProcessError':
|
|
9
|
+
return `Process error: ${error.message}`;
|
|
10
|
+
case 'ConfigError':
|
|
11
|
+
return `Configuration error (${error.reason}): ${error.details}`;
|
|
12
|
+
case 'GitError':
|
|
13
|
+
return `Git command failed: ${error.command} (exit ${error.exitCode})\n${error.stderr}`;
|
|
14
|
+
case 'FileSystemError':
|
|
15
|
+
return `File ${error.operation} failed for ${error.path}: ${error.cause}`;
|
|
16
|
+
case 'ValidationError':
|
|
17
|
+
return `Validation failed for ${error.field}: ${error.constraint}`;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -8,6 +8,7 @@ const makeItem = (searchableName, path) => ({
|
|
|
8
8
|
hasSession: false,
|
|
9
9
|
},
|
|
10
10
|
baseLabel: searchableName,
|
|
11
|
+
status: '',
|
|
11
12
|
searchableName,
|
|
12
13
|
fileChanges: '',
|
|
13
14
|
aheadBehind: '',
|
|
@@ -15,6 +16,7 @@ const makeItem = (searchableName, path) => ({
|
|
|
15
16
|
lastCommitDate: '',
|
|
16
17
|
lengths: {
|
|
17
18
|
base: 0,
|
|
19
|
+
status: 0,
|
|
18
20
|
fileChanges: 0,
|
|
19
21
|
aheadBehind: 0,
|
|
20
22
|
parentBranch: 0,
|
package/dist/utils/gitUtils.d.ts
CHANGED
|
@@ -15,3 +15,4 @@ export declare function hasUncommittedChanges(worktreePath: string): boolean;
|
|
|
15
15
|
* @returns The absolute path to the git repository root, or null if not in a git repo
|
|
16
16
|
*/
|
|
17
17
|
export declare function getGitRepositoryRoot(cwd: string): string | null;
|
|
18
|
+
export declare function getCurrentRepositoryRoot(): string;
|
package/dist/utils/gitUtils.js
CHANGED
|
@@ -62,3 +62,18 @@ export function getGitRepositoryRoot(cwd) {
|
|
|
62
62
|
return null;
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Repository root of the directory ccmanager was started in.
|
|
67
|
+
*
|
|
68
|
+
* Cached after the first call: it cannot change while the process runs, and
|
|
69
|
+
* both the code that records sessions for restore and the code that looks them
|
|
70
|
+
* up need the exact same value to agree on which project a session belongs to.
|
|
71
|
+
*/
|
|
72
|
+
let currentRepositoryRoot;
|
|
73
|
+
export function getCurrentRepositoryRoot() {
|
|
74
|
+
if (currentRepositoryRoot === undefined) {
|
|
75
|
+
const cwd = process.cwd();
|
|
76
|
+
currentRepositoryRoot = getGitRepositoryRoot(cwd) ?? path.resolve(cwd);
|
|
77
|
+
}
|
|
78
|
+
return currentRepositoryRoot;
|
|
79
|
+
}
|
|
@@ -393,6 +393,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
393
393
|
stateCheckInterval: undefined,
|
|
394
394
|
isPrimaryCommand: true,
|
|
395
395
|
presetName: undefined,
|
|
396
|
+
presetId: undefined,
|
|
396
397
|
detectionStrategy: 'claude',
|
|
397
398
|
devcontainerConfig: undefined,
|
|
398
399
|
lastActivity: new Date(),
|
|
@@ -453,6 +454,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
453
454
|
stateCheckInterval: undefined,
|
|
454
455
|
isPrimaryCommand: true,
|
|
455
456
|
presetName: undefined,
|
|
457
|
+
presetId: undefined,
|
|
456
458
|
detectionStrategy: 'claude',
|
|
457
459
|
devcontainerConfig: undefined,
|
|
458
460
|
lastActivity: new Date(),
|
|
@@ -511,6 +513,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
511
513
|
stateCheckInterval: undefined,
|
|
512
514
|
isPrimaryCommand: true,
|
|
513
515
|
presetName: undefined,
|
|
516
|
+
presetId: undefined,
|
|
514
517
|
detectionStrategy: 'claude',
|
|
515
518
|
devcontainerConfig: undefined,
|
|
516
519
|
lastActivity: new Date(),
|
|
@@ -571,6 +574,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
571
574
|
stateCheckInterval: undefined,
|
|
572
575
|
isPrimaryCommand: true,
|
|
573
576
|
presetName: undefined,
|
|
577
|
+
presetId: undefined,
|
|
574
578
|
detectionStrategy: 'claude',
|
|
575
579
|
devcontainerConfig: undefined,
|
|
576
580
|
lastActivity: new Date(),
|
|
@@ -6,6 +6,7 @@ export interface SessionItem {
|
|
|
6
6
|
worktree: Worktree;
|
|
7
7
|
session?: Session;
|
|
8
8
|
baseLabel: string;
|
|
9
|
+
status: string;
|
|
9
10
|
searchableName: string;
|
|
10
11
|
fileChanges: string;
|
|
11
12
|
aheadBehind: string;
|
|
@@ -14,6 +15,7 @@ export interface SessionItem {
|
|
|
14
15
|
error?: string;
|
|
15
16
|
lengths: {
|
|
16
17
|
base: number;
|
|
18
|
+
status: number;
|
|
17
19
|
fileChanges: number;
|
|
18
20
|
aheadBehind: number;
|
|
19
21
|
parentBranch: number;
|
|
@@ -66,18 +68,37 @@ export declare function prepareSessionItems(worktrees: Worktree[], sessions: Ses
|
|
|
66
68
|
sortByLastSession?: boolean;
|
|
67
69
|
}): SessionItem[];
|
|
68
70
|
/**
|
|
69
|
-
*
|
|
71
|
+
* Column start positions for one rendered list, plus how the session state tag
|
|
72
|
+
* (e.g. "[○ Idle]") is placed.
|
|
70
73
|
*/
|
|
71
|
-
export
|
|
74
|
+
export interface ColumnPositions {
|
|
72
75
|
fileChanges: number;
|
|
73
76
|
aheadBehind: number;
|
|
74
77
|
parentBranch: number;
|
|
78
|
+
status: number;
|
|
75
79
|
lastCommitDate: number;
|
|
76
|
-
|
|
80
|
+
/**
|
|
81
|
+
* true: the state tag gets its own column at `status`, so every row's tag
|
|
82
|
+
* starts at the same horizontal position, directly left of the commit date.
|
|
83
|
+
* false: the state tag is appended right after the branch name (the layout
|
|
84
|
+
* used before this column existed), because the aligned layout would not fit
|
|
85
|
+
* the terminal width given to calculateColumnPositions.
|
|
86
|
+
*/
|
|
87
|
+
alignStatus: boolean;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Calculates column positions based on content widths.
|
|
91
|
+
*
|
|
92
|
+
* `availableWidth` is the number of terminal columns the label may occupy
|
|
93
|
+
* (i.e. terminal width minus whatever prefix the caller prepends). When the
|
|
94
|
+
* aligned-status layout would not fit in it, the layout falls back to appending
|
|
95
|
+
* the state tag to the name, which is narrower. Omit it to always align.
|
|
96
|
+
*/
|
|
97
|
+
export declare function calculateColumnPositions(items: SessionItem[], availableWidth?: number): ColumnPositions;
|
|
77
98
|
/**
|
|
78
99
|
* Assembles the final worktree label with proper column alignment
|
|
79
100
|
*/
|
|
80
|
-
export declare function assembleSessionLabel(item: SessionItem, columns:
|
|
101
|
+
export declare function assembleSessionLabel(item: SessionItem, columns: ColumnPositions): string;
|
|
81
102
|
/**
|
|
82
103
|
* Whether a worktree may be deleted by CCManager.
|
|
83
104
|
*
|
|
@@ -206,7 +206,7 @@ function gitStatusColumns(wt, fullBranchName) {
|
|
|
206
206
|
function buildSessionItem(wt, session, sessionSuffix) {
|
|
207
207
|
const stateData = session?.stateMutex.getSnapshot();
|
|
208
208
|
const status = stateData
|
|
209
|
-
? `
|
|
209
|
+
? `[${getStatusDisplay(stateData.state, stateData.backgroundTaskCount, stateData.teamMemberCount)}]`
|
|
210
210
|
: '';
|
|
211
211
|
const fullBranchName = wt.branch
|
|
212
212
|
? wt.branch.replace('refs/heads/', '')
|
|
@@ -214,7 +214,7 @@ function buildSessionItem(wt, session, sessionSuffix) {
|
|
|
214
214
|
const branchName = truncateString(fullBranchName, MAX_BRANCH_NAME_LENGTH);
|
|
215
215
|
const isMain = wt.isMainWorktree ? ' (main)' : '';
|
|
216
216
|
const { displaySuffix: dirSuffix, rawName: rawDirName } = formatWorktreeDirectorySuffix(wt, fullBranchName);
|
|
217
|
-
const baseLabel = `${branchName}${dirSuffix}${isMain}${sessionSuffix}
|
|
217
|
+
const baseLabel = `${branchName}${dirSuffix}${isMain}${sessionSuffix}`;
|
|
218
218
|
// Use the full (untruncated) branch name so search still matches the tail
|
|
219
219
|
// of long branch names; status icons are excluded so they don't match.
|
|
220
220
|
const rawDirForSearch = rawDirName ? ` @ ${rawDirName}` : '';
|
|
@@ -227,6 +227,7 @@ function buildSessionItem(wt, session, sessionSuffix) {
|
|
|
227
227
|
worktree: wt,
|
|
228
228
|
session,
|
|
229
229
|
baseLabel,
|
|
230
|
+
status,
|
|
230
231
|
searchableName,
|
|
231
232
|
fileChanges,
|
|
232
233
|
aheadBehind,
|
|
@@ -235,6 +236,7 @@ function buildSessionItem(wt, session, sessionSuffix) {
|
|
|
235
236
|
error,
|
|
236
237
|
lengths: {
|
|
237
238
|
base: stripAnsi(baseLabel).length,
|
|
239
|
+
status: stripAnsi(status).length,
|
|
238
240
|
fileChanges: stripAnsi(fileChanges).length,
|
|
239
241
|
aheadBehind: stripAnsi(aheadBehind).length,
|
|
240
242
|
parentBranch: stripAnsi(parentBranch).length,
|
|
@@ -277,35 +279,59 @@ export function prepareSessionItems(worktrees, sessions, options) {
|
|
|
277
279
|
}
|
|
278
280
|
return items;
|
|
279
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* Visible width of the name portion when the state tag is appended to it
|
|
284
|
+
* instead of getting its own column.
|
|
285
|
+
*/
|
|
286
|
+
function inlineBaseLength(item) {
|
|
287
|
+
return (item.lengths.base + (item.lengths.status ? item.lengths.status + 1 : 0));
|
|
288
|
+
}
|
|
280
289
|
/**
|
|
281
290
|
* Calculates column positions based on content widths.
|
|
291
|
+
*
|
|
292
|
+
* `availableWidth` is the number of terminal columns the label may occupy
|
|
293
|
+
* (i.e. terminal width minus whatever prefix the caller prepends). When the
|
|
294
|
+
* aligned-status layout would not fit in it, the layout falls back to appending
|
|
295
|
+
* the state tag to the name, which is narrower. Omit it to always align.
|
|
282
296
|
*/
|
|
283
|
-
export function calculateColumnPositions(items) {
|
|
297
|
+
export function calculateColumnPositions(items, availableWidth) {
|
|
284
298
|
// Calculate maximum widths from pre-calculated lengths
|
|
285
299
|
let maxBranchLength = 0;
|
|
300
|
+
let maxInlineBranchLength = 0;
|
|
301
|
+
let maxStatusLength = 0;
|
|
286
302
|
let maxFileChangesLength = 0;
|
|
287
303
|
let maxAheadBehindLength = 0;
|
|
288
304
|
let maxParentBranchLength = 0;
|
|
305
|
+
let maxLastCommitDateLength = 0;
|
|
289
306
|
items.forEach(item => {
|
|
290
307
|
// Skip items with errors for alignment calculation
|
|
291
308
|
if (item.error)
|
|
292
309
|
return;
|
|
293
310
|
maxBranchLength = Math.max(maxBranchLength, item.lengths.base);
|
|
311
|
+
maxInlineBranchLength = Math.max(maxInlineBranchLength, inlineBaseLength(item));
|
|
312
|
+
maxStatusLength = Math.max(maxStatusLength, item.lengths.status);
|
|
294
313
|
maxFileChangesLength = Math.max(maxFileChangesLength, item.lengths.fileChanges);
|
|
295
314
|
maxAheadBehindLength = Math.max(maxAheadBehindLength, item.lengths.aheadBehind);
|
|
296
315
|
maxParentBranchLength = Math.max(maxParentBranchLength, item.lengths.parentBranch);
|
|
316
|
+
maxLastCommitDateLength = Math.max(maxLastCommitDateLength, item.lengths.lastCommitDate);
|
|
297
317
|
});
|
|
298
|
-
// Simple column positioning
|
|
299
|
-
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
parentBranch
|
|
307
|
-
lastCommitDate: lastCommitDateColumn,
|
|
318
|
+
// Simple column positioning. `branchWidth` is how much room the name portion
|
|
319
|
+
// gets, `statusWidth` is 0 when the state tag rides along with the name.
|
|
320
|
+
const layout = (branchWidth, statusWidth) => {
|
|
321
|
+
const fileChanges = branchWidth + MIN_COLUMN_PADDING;
|
|
322
|
+
const aheadBehind = fileChanges + maxFileChangesLength + MIN_COLUMN_PADDING + 2;
|
|
323
|
+
const parentBranch = aheadBehind + maxAheadBehindLength + MIN_COLUMN_PADDING + 2;
|
|
324
|
+
const status = parentBranch + maxParentBranchLength + MIN_COLUMN_PADDING + 2;
|
|
325
|
+
const lastCommitDate = status + (statusWidth ? statusWidth + MIN_COLUMN_PADDING : 0);
|
|
326
|
+
return { fileChanges, aheadBehind, parentBranch, status, lastCommitDate };
|
|
308
327
|
};
|
|
328
|
+
const aligned = layout(maxBranchLength, maxStatusLength);
|
|
329
|
+
const fits = availableWidth === undefined ||
|
|
330
|
+
aligned.lastCommitDate + maxLastCommitDateLength <= availableWidth;
|
|
331
|
+
if (fits) {
|
|
332
|
+
return { ...aligned, alignStatus: true };
|
|
333
|
+
}
|
|
334
|
+
return { ...layout(maxInlineBranchLength, 0), alignStatus: false };
|
|
309
335
|
}
|
|
310
336
|
// Pad string to column position
|
|
311
337
|
function padTo(str, visibleLength, column) {
|
|
@@ -315,12 +341,18 @@ function padTo(str, visibleLength, column) {
|
|
|
315
341
|
* Assembles the final worktree label with proper column alignment
|
|
316
342
|
*/
|
|
317
343
|
export function assembleSessionLabel(item, columns) {
|
|
318
|
-
|
|
344
|
+
const inlineStatus = item.status
|
|
345
|
+
? `${item.baseLabel} ${item.status}`
|
|
346
|
+
: item.baseLabel;
|
|
347
|
+
// If there's an error, just show the base label with error appended.
|
|
348
|
+
// Error rows carry no columns at all, so the state tag stays next to the name.
|
|
319
349
|
if (item.error) {
|
|
320
|
-
return `${
|
|
350
|
+
return `${inlineStatus} ${item.error}`;
|
|
321
351
|
}
|
|
322
|
-
let label = item.baseLabel;
|
|
323
|
-
let currentLength =
|
|
352
|
+
let label = columns.alignStatus ? item.baseLabel : inlineStatus;
|
|
353
|
+
let currentLength = columns.alignStatus
|
|
354
|
+
? item.lengths.base
|
|
355
|
+
: inlineBaseLength(item);
|
|
324
356
|
if (item.fileChanges) {
|
|
325
357
|
label = padTo(label, currentLength, columns.fileChanges) + item.fileChanges;
|
|
326
358
|
currentLength = columns.fileChanges + item.lengths.fileChanges;
|
|
@@ -334,6 +366,10 @@ export function assembleSessionLabel(item, columns) {
|
|
|
334
366
|
padTo(label, currentLength, columns.parentBranch) + item.parentBranch;
|
|
335
367
|
currentLength = columns.parentBranch + item.lengths.parentBranch;
|
|
336
368
|
}
|
|
369
|
+
if (columns.alignStatus && item.status) {
|
|
370
|
+
label = padTo(label, currentLength, columns.status) + item.status;
|
|
371
|
+
currentLength = columns.status + item.lengths.status;
|
|
372
|
+
}
|
|
337
373
|
if (item.lastCommitDate) {
|
|
338
374
|
label =
|
|
339
375
|
padTo(label, currentLength, columns.lastCommitDate) + item.lastCommitDate;
|
|
@@ -139,6 +139,7 @@ describe('prepareSessionItems', () => {
|
|
|
139
139
|
stateCheckInterval: undefined,
|
|
140
140
|
isPrimaryCommand: true,
|
|
141
141
|
presetName: undefined,
|
|
142
|
+
presetId: undefined,
|
|
142
143
|
detectionStrategy: 'claude',
|
|
143
144
|
devcontainerConfig: undefined,
|
|
144
145
|
stateMutex: new Mutex({
|
|
@@ -152,9 +153,12 @@ describe('prepareSessionItems', () => {
|
|
|
152
153
|
expect(items).toHaveLength(1);
|
|
153
154
|
expect(items[0]?.baseLabel).toBe('feature/test-branch');
|
|
154
155
|
});
|
|
155
|
-
it('should
|
|
156
|
+
it('should expose the session status separately from the name', () => {
|
|
156
157
|
const items = prepareSessionItems([mockWorktree], [mockSession]);
|
|
157
|
-
|
|
158
|
+
// The status tag is its own field so it can be rendered as an aligned
|
|
159
|
+
// column; it must not be baked into the name portion.
|
|
160
|
+
expect(items[0]?.status).toBe('[○ Idle]');
|
|
161
|
+
expect(items[0]?.baseLabel).toBe('feature/test-branch');
|
|
158
162
|
});
|
|
159
163
|
it('should mark main worktree', () => {
|
|
160
164
|
const mainWorktree = { ...mockWorktree, isMainWorktree: true };
|
|
@@ -249,8 +253,9 @@ describe('prepareSessionItems', () => {
|
|
|
249
253
|
sessionName: 'lab',
|
|
250
254
|
},
|
|
251
255
|
]);
|
|
252
|
-
// Order must be: branch, dir suffix, (no main), session suffix
|
|
253
|
-
expect(items[0]?.baseLabel).
|
|
256
|
+
// Order must be: branch, dir suffix, (no main), session suffix.
|
|
257
|
+
expect(items[0]?.baseLabel).toBe('feature/foo @ foo-api: lab');
|
|
258
|
+
expect(items[0]?.status).toMatch(/^\[.*Idle.*\]$/);
|
|
254
259
|
});
|
|
255
260
|
it('does not break column alignment when a dir suffix is appended', () => {
|
|
256
261
|
const items = prepareSessionItems([
|
|
@@ -277,6 +282,7 @@ describe('column alignment', () => {
|
|
|
277
282
|
{
|
|
278
283
|
worktree: {},
|
|
279
284
|
baseLabel: 'feature/test-branch',
|
|
285
|
+
status: '',
|
|
280
286
|
searchableName: 'feature/test-branch',
|
|
281
287
|
fileChanges: '\x1b[32m+10\x1b[0m \x1b[31m-5\x1b[0m',
|
|
282
288
|
aheadBehind: '\x1b[33m↑2 ↓3\x1b[0m',
|
|
@@ -284,6 +290,7 @@ describe('column alignment', () => {
|
|
|
284
290
|
lastCommitDate: '',
|
|
285
291
|
lengths: {
|
|
286
292
|
base: 19, // 'feature/test-branch'.length
|
|
293
|
+
status: 0,
|
|
287
294
|
fileChanges: 6, // '+10 -5'.length
|
|
288
295
|
aheadBehind: 5, // '↑2 ↓3'.length
|
|
289
296
|
parentBranch: 0,
|
|
@@ -293,6 +300,7 @@ describe('column alignment', () => {
|
|
|
293
300
|
{
|
|
294
301
|
worktree: {},
|
|
295
302
|
baseLabel: 'main',
|
|
303
|
+
status: '',
|
|
296
304
|
searchableName: 'main',
|
|
297
305
|
fileChanges: '\x1b[32m+2\x1b[0m \x1b[31m-1\x1b[0m',
|
|
298
306
|
aheadBehind: '\x1b[33m↑1\x1b[0m',
|
|
@@ -300,6 +308,7 @@ describe('column alignment', () => {
|
|
|
300
308
|
lastCommitDate: '',
|
|
301
309
|
lengths: {
|
|
302
310
|
base: 4, // 'main'.length
|
|
311
|
+
status: 0,
|
|
303
312
|
fileChanges: 5, // '+2 -1'.length
|
|
304
313
|
aheadBehind: 2, // '↑1'.length
|
|
305
314
|
parentBranch: 0,
|
|
@@ -344,3 +353,52 @@ describe('isDeletableWorktree', () => {
|
|
|
344
353
|
expect(isDeletableWorktree({ path: '/repo/worktrees/feature', isMainWorktree: false }, '/repo')).toBe(true);
|
|
345
354
|
});
|
|
346
355
|
});
|
|
356
|
+
describe('session status column', () => {
|
|
357
|
+
const makeItem = (baseLabel, status, lastCommitDate) => ({
|
|
358
|
+
worktree: {},
|
|
359
|
+
baseLabel,
|
|
360
|
+
status,
|
|
361
|
+
searchableName: baseLabel,
|
|
362
|
+
fileChanges: '',
|
|
363
|
+
aheadBehind: '',
|
|
364
|
+
parentBranch: '',
|
|
365
|
+
lastCommitDate,
|
|
366
|
+
lengths: {
|
|
367
|
+
base: baseLabel.length,
|
|
368
|
+
status: status.length,
|
|
369
|
+
fileChanges: 0,
|
|
370
|
+
aheadBehind: 0,
|
|
371
|
+
parentBranch: 0,
|
|
372
|
+
lastCommitDate: lastCommitDate.length,
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
const items = [
|
|
376
|
+
makeItem('feature/a-very-long-branch-name', '[○ Idle]', '1d ago'),
|
|
377
|
+
makeItem('main', '[● Busy]', '3w ago'),
|
|
378
|
+
];
|
|
379
|
+
it('starts every status tag at the same column, just left of the date', () => {
|
|
380
|
+
const columns = calculateColumnPositions(items, 120);
|
|
381
|
+
expect(columns.alignStatus).toBe(true);
|
|
382
|
+
const labels = items.map(item => assembleSessionLabel(item, columns));
|
|
383
|
+
for (const [index, label] of labels.entries()) {
|
|
384
|
+
expect(label.indexOf(items[index].status)).toBe(columns.status);
|
|
385
|
+
expect(label.indexOf(items[index].lastCommitDate)).toBe(columns.lastCommitDate);
|
|
386
|
+
}
|
|
387
|
+
// The gap between the tag and the date is only the column padding.
|
|
388
|
+
expect(columns.lastCommitDate - columns.status).toBe('[○ Idle]'.length + 2);
|
|
389
|
+
});
|
|
390
|
+
it('falls back to appending the status to the name when too narrow', () => {
|
|
391
|
+
const columns = calculateColumnPositions(items, 40);
|
|
392
|
+
expect(columns.alignStatus).toBe(false);
|
|
393
|
+
expect(assembleSessionLabel(items[0], columns)).toContain('feature/a-very-long-branch-name [○ Idle]');
|
|
394
|
+
expect(assembleSessionLabel(items[1], columns)).toContain('main [● Busy]');
|
|
395
|
+
});
|
|
396
|
+
it('keeps the status next to the name on rows showing a git error', () => {
|
|
397
|
+
const errored = {
|
|
398
|
+
...makeItem('main', '[○ Idle]', ''),
|
|
399
|
+
error: '[git error]',
|
|
400
|
+
};
|
|
401
|
+
const columns = calculateColumnPositions([...items, errored], 120);
|
|
402
|
+
expect(assembleSessionLabel(errored, columns)).toBe('main [○ Idle] [git error]');
|
|
403
|
+
});
|
|
404
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.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": "4.
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "4.
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "4.
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "4.
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "4.
|
|
44
|
+
"@kodaikabasawa/ccmanager-darwin-arm64": "4.4.0",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "4.4.0",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "4.4.0",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "4.4.0",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "4.4.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|