ccmanager 2.8.0 → 2.9.1
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/cli.test.js +13 -2
- package/dist/components/App.js +125 -50
- package/dist/components/App.test.js +270 -0
- package/dist/components/ConfigureShortcuts.js +82 -8
- package/dist/components/DeleteWorktree.js +39 -5
- package/dist/components/DeleteWorktree.test.d.ts +1 -0
- package/dist/components/DeleteWorktree.test.js +128 -0
- package/dist/components/LoadingSpinner.d.ts +8 -0
- package/dist/components/LoadingSpinner.js +37 -0
- package/dist/components/LoadingSpinner.test.d.ts +1 -0
- package/dist/components/LoadingSpinner.test.js +187 -0
- package/dist/components/Menu.js +64 -16
- package/dist/components/Menu.recent-projects.test.js +32 -11
- package/dist/components/Menu.test.js +136 -4
- package/dist/components/MergeWorktree.js +79 -18
- package/dist/components/MergeWorktree.test.d.ts +1 -0
- package/dist/components/MergeWorktree.test.js +227 -0
- package/dist/components/NewWorktree.js +88 -9
- package/dist/components/NewWorktree.test.d.ts +1 -0
- package/dist/components/NewWorktree.test.js +244 -0
- package/dist/components/ProjectList.js +44 -13
- package/dist/components/ProjectList.recent-projects.test.js +8 -3
- package/dist/components/ProjectList.test.js +105 -8
- package/dist/components/RemoteBranchSelector.test.js +3 -1
- package/dist/components/Session.js +11 -6
- package/dist/hooks/useGitStatus.d.ts +11 -0
- package/dist/hooks/useGitStatus.js +70 -12
- package/dist/hooks/useGitStatus.test.js +30 -23
- package/dist/services/configurationManager.d.ts +75 -0
- package/dist/services/configurationManager.effect.test.d.ts +1 -0
- package/dist/services/configurationManager.effect.test.js +407 -0
- package/dist/services/configurationManager.js +246 -0
- package/dist/services/globalSessionOrchestrator.test.js +0 -8
- package/dist/services/projectManager.d.ts +98 -2
- package/dist/services/projectManager.js +228 -59
- package/dist/services/projectManager.test.js +242 -2
- package/dist/services/sessionManager.d.ts +44 -2
- package/dist/services/sessionManager.effect.test.d.ts +1 -0
- package/dist/services/sessionManager.effect.test.js +321 -0
- package/dist/services/sessionManager.js +216 -65
- package/dist/services/sessionManager.statePersistence.test.js +18 -9
- package/dist/services/sessionManager.test.js +40 -36
- package/dist/services/shortcutManager.d.ts +2 -0
- package/dist/services/shortcutManager.js +53 -0
- package/dist/services/shortcutManager.test.d.ts +1 -0
- package/dist/services/shortcutManager.test.js +30 -0
- package/dist/services/worktreeService.d.ts +356 -26
- package/dist/services/worktreeService.js +793 -353
- package/dist/services/worktreeService.test.js +294 -313
- package/dist/types/errors.d.ts +74 -0
- package/dist/types/errors.js +31 -0
- package/dist/types/errors.test.d.ts +1 -0
- package/dist/types/errors.test.js +201 -0
- package/dist/types/index.d.ts +5 -17
- package/dist/utils/claudeDir.d.ts +58 -6
- package/dist/utils/claudeDir.js +103 -8
- package/dist/utils/claudeDir.test.d.ts +1 -0
- package/dist/utils/claudeDir.test.js +108 -0
- package/dist/utils/concurrencyLimit.d.ts +5 -0
- package/dist/utils/concurrencyLimit.js +11 -0
- package/dist/utils/concurrencyLimit.test.js +40 -1
- package/dist/utils/gitStatus.d.ts +36 -8
- package/dist/utils/gitStatus.js +170 -88
- package/dist/utils/gitStatus.test.js +12 -9
- package/dist/utils/hookExecutor.d.ts +41 -6
- package/dist/utils/hookExecutor.js +75 -32
- package/dist/utils/hookExecutor.test.js +73 -20
- package/dist/utils/terminalCapabilities.d.ts +18 -0
- package/dist/utils/terminalCapabilities.js +81 -0
- package/dist/utils/terminalCapabilities.test.d.ts +1 -0
- package/dist/utils/terminalCapabilities.test.js +104 -0
- package/dist/utils/testHelpers.d.ts +106 -0
- package/dist/utils/testHelpers.js +153 -0
- package/dist/utils/testHelpers.test.d.ts +1 -0
- package/dist/utils/testHelpers.test.js +114 -0
- package/dist/utils/worktreeConfig.d.ts +77 -2
- package/dist/utils/worktreeConfig.js +156 -16
- package/dist/utils/worktreeConfig.test.d.ts +1 -0
- package/dist/utils/worktreeConfig.test.js +39 -0
- package/package.json +4 -4
- package/dist/integration-tests/devcontainer.integration.test.js +0 -101
- /package/dist/{integration-tests/devcontainer.integration.test.d.ts → components/App.test.d.ts} +0 -0
|
@@ -3,7 +3,9 @@ import { SessionManager } from './sessionManager.js';
|
|
|
3
3
|
import { spawn } from 'node-pty';
|
|
4
4
|
import { EventEmitter } from 'events';
|
|
5
5
|
import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, } from '../constants/statePersistence.js';
|
|
6
|
-
vi.mock('node-pty')
|
|
6
|
+
vi.mock('node-pty', () => ({
|
|
7
|
+
spawn: vi.fn(),
|
|
8
|
+
}));
|
|
7
9
|
vi.mock('./configurationManager.js', () => ({
|
|
8
10
|
configurationManager: {
|
|
9
11
|
getConfig: vi.fn().mockReturnValue({
|
|
@@ -72,7 +74,8 @@ describe('SessionManager - State Persistence', () => {
|
|
|
72
74
|
vi.clearAllMocks();
|
|
73
75
|
});
|
|
74
76
|
it('should not change state immediately when detected state changes', async () => {
|
|
75
|
-
const
|
|
77
|
+
const { Effect } = await import('effect');
|
|
78
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
76
79
|
const eventEmitter = eventEmitters.get('/test/path');
|
|
77
80
|
// Initial state should be busy
|
|
78
81
|
expect(session.state).toBe('busy');
|
|
@@ -86,7 +89,8 @@ describe('SessionManager - State Persistence', () => {
|
|
|
86
89
|
expect(session.pendingStateStart).toBeDefined();
|
|
87
90
|
});
|
|
88
91
|
it('should change state after persistence duration is met', async () => {
|
|
89
|
-
const
|
|
92
|
+
const { Effect } = await import('effect');
|
|
93
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
90
94
|
const eventEmitter = eventEmitters.get('/test/path');
|
|
91
95
|
const stateChangeHandler = vi.fn();
|
|
92
96
|
sessionManager.on('sessionStateChanged', stateChangeHandler);
|
|
@@ -107,7 +111,8 @@ describe('SessionManager - State Persistence', () => {
|
|
|
107
111
|
expect(stateChangeHandler).toHaveBeenCalledWith(session);
|
|
108
112
|
});
|
|
109
113
|
it('should cancel pending state if detected state changes again before persistence', async () => {
|
|
110
|
-
const
|
|
114
|
+
const { Effect } = await import('effect');
|
|
115
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
111
116
|
const eventEmitter = eventEmitters.get('/test/path');
|
|
112
117
|
// Initial state should be busy
|
|
113
118
|
expect(session.state).toBe('busy');
|
|
@@ -125,7 +130,8 @@ describe('SessionManager - State Persistence', () => {
|
|
|
125
130
|
expect(session.pendingState).toBe('waiting_input');
|
|
126
131
|
});
|
|
127
132
|
it('should clear pending state if detected state returns to current state', async () => {
|
|
128
|
-
const
|
|
133
|
+
const { Effect } = await import('effect');
|
|
134
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
129
135
|
const eventEmitter = eventEmitters.get('/test/path');
|
|
130
136
|
// Initial state should be busy
|
|
131
137
|
expect(session.state).toBe('busy');
|
|
@@ -145,7 +151,8 @@ describe('SessionManager - State Persistence', () => {
|
|
|
145
151
|
expect(session.pendingStateStart).toBeUndefined();
|
|
146
152
|
});
|
|
147
153
|
it('should not confirm state changes that do not persist long enough', async () => {
|
|
148
|
-
const
|
|
154
|
+
const { Effect } = await import('effect');
|
|
155
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
149
156
|
const eventEmitter = eventEmitters.get('/test/path');
|
|
150
157
|
const stateChangeHandler = vi.fn();
|
|
151
158
|
sessionManager.on('sessionStateChanged', stateChangeHandler);
|
|
@@ -170,7 +177,8 @@ describe('SessionManager - State Persistence', () => {
|
|
|
170
177
|
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
171
178
|
});
|
|
172
179
|
it('should properly clean up pending state when session is destroyed', async () => {
|
|
173
|
-
const
|
|
180
|
+
const { Effect } = await import('effect');
|
|
181
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
174
182
|
const eventEmitter = eventEmitters.get('/test/path');
|
|
175
183
|
// Simulate output that would trigger idle state
|
|
176
184
|
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
@@ -185,8 +193,9 @@ describe('SessionManager - State Persistence', () => {
|
|
|
185
193
|
expect(destroyedSession).toBeUndefined();
|
|
186
194
|
});
|
|
187
195
|
it('should handle multiple sessions with independent state persistence', async () => {
|
|
188
|
-
const
|
|
189
|
-
const
|
|
196
|
+
const { Effect } = await import('effect');
|
|
197
|
+
const session1 = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path1'));
|
|
198
|
+
const session2 = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path2'));
|
|
190
199
|
const eventEmitter1 = eventEmitters.get('/test/path1');
|
|
191
200
|
const eventEmitter2 = eventEmitters.get('/test/path2');
|
|
192
201
|
// Both should start as busy
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { Effect } from 'effect';
|
|
2
3
|
import { spawn } from 'node-pty';
|
|
3
4
|
import { EventEmitter } from 'events';
|
|
4
5
|
import { exec } from 'child_process';
|
|
5
6
|
// Mock node-pty
|
|
6
|
-
vi.mock('node-pty')
|
|
7
|
+
vi.mock('node-pty', () => ({
|
|
8
|
+
spawn: vi.fn(),
|
|
9
|
+
}));
|
|
7
10
|
// Mock child_process
|
|
8
11
|
vi.mock('child_process', () => ({
|
|
9
12
|
exec: vi.fn(),
|
|
13
|
+
execFile: vi.fn(),
|
|
10
14
|
}));
|
|
11
15
|
// Mock configuration manager
|
|
12
16
|
vi.mock('./configurationManager.js', () => ({
|
|
@@ -93,7 +97,7 @@ describe('SessionManager', () => {
|
|
|
93
97
|
afterEach(() => {
|
|
94
98
|
sessionManager.destroy();
|
|
95
99
|
});
|
|
96
|
-
describe('
|
|
100
|
+
describe('createSessionWithPresetEffect', () => {
|
|
97
101
|
it('should use default preset when no preset ID specified', async () => {
|
|
98
102
|
// Setup mock preset
|
|
99
103
|
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
@@ -105,7 +109,7 @@ describe('SessionManager', () => {
|
|
|
105
109
|
// Setup spawn mock
|
|
106
110
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
107
111
|
// Create session with preset
|
|
108
|
-
await sessionManager.
|
|
112
|
+
await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
109
113
|
// Verify spawn was called with preset config
|
|
110
114
|
expect(spawn).toHaveBeenCalledWith('claude', ['--preset-arg'], {
|
|
111
115
|
name: 'xterm-256color',
|
|
@@ -127,7 +131,7 @@ describe('SessionManager', () => {
|
|
|
127
131
|
// Setup spawn mock
|
|
128
132
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
129
133
|
// Create session with specific preset
|
|
130
|
-
await sessionManager.
|
|
134
|
+
await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree', '2'));
|
|
131
135
|
// Verify getPresetById was called with correct ID
|
|
132
136
|
expect(configurationManager.getPresetById).toHaveBeenCalledWith('2');
|
|
133
137
|
// Verify spawn was called with preset config
|
|
@@ -150,7 +154,7 @@ describe('SessionManager', () => {
|
|
|
150
154
|
// Setup spawn mock
|
|
151
155
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
152
156
|
// Create session with non-existent preset
|
|
153
|
-
await sessionManager.
|
|
157
|
+
await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree', 'invalid'));
|
|
154
158
|
// Verify fallback to default preset
|
|
155
159
|
expect(configurationManager.getDefaultPreset).toHaveBeenCalled();
|
|
156
160
|
expect(spawn).toHaveBeenCalledWith('claude', [], expect.any(Object));
|
|
@@ -168,8 +172,8 @@ describe('SessionManager', () => {
|
|
|
168
172
|
vi.mocked(spawn).mockImplementation(() => {
|
|
169
173
|
throw new Error('Command failed');
|
|
170
174
|
});
|
|
171
|
-
// Expect
|
|
172
|
-
await expect(sessionManager.
|
|
175
|
+
// Expect createSessionWithPresetEffect to throw
|
|
176
|
+
await expect(Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'))).rejects.toThrow('Command failed');
|
|
173
177
|
// Verify only one spawn attempt was made
|
|
174
178
|
expect(spawn).toHaveBeenCalledTimes(1);
|
|
175
179
|
expect(spawn).toHaveBeenCalledWith('claude', ['--bad-flag'], expect.any(Object));
|
|
@@ -184,8 +188,8 @@ describe('SessionManager', () => {
|
|
|
184
188
|
// Setup spawn mock
|
|
185
189
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
186
190
|
// Create session twice
|
|
187
|
-
const session1 = await sessionManager.
|
|
188
|
-
const session2 = await sessionManager.
|
|
191
|
+
const session1 = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
192
|
+
const session2 = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
189
193
|
// Should return the same session
|
|
190
194
|
expect(session1).toBe(session2);
|
|
191
195
|
// Spawn should only be called once
|
|
@@ -204,8 +208,8 @@ describe('SessionManager', () => {
|
|
|
204
208
|
vi.mocked(spawn).mockImplementation(() => {
|
|
205
209
|
throw new Error('Command not found');
|
|
206
210
|
});
|
|
207
|
-
// Expect
|
|
208
|
-
await expect(sessionManager.
|
|
211
|
+
// Expect createSessionWithPresetEffect to throw the original error
|
|
212
|
+
await expect(Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'))).rejects.toThrow('Command not found');
|
|
209
213
|
});
|
|
210
214
|
it('should use fallback args when main command exits with code 1', async () => {
|
|
211
215
|
// Setup mock preset with fallback
|
|
@@ -224,7 +228,7 @@ describe('SessionManager', () => {
|
|
|
224
228
|
.mockReturnValueOnce(firstMockPty)
|
|
225
229
|
.mockReturnValueOnce(secondMockPty);
|
|
226
230
|
// Create session
|
|
227
|
-
const session = await sessionManager.
|
|
231
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
228
232
|
// Verify initial spawn
|
|
229
233
|
expect(spawn).toHaveBeenCalledTimes(1);
|
|
230
234
|
expect(spawn).toHaveBeenCalledWith('claude', ['--invalid-flag'], expect.objectContaining({ cwd: '/test/worktree' }));
|
|
@@ -251,7 +255,7 @@ describe('SessionManager', () => {
|
|
|
251
255
|
// Setup spawn mock - process doesn't exit early
|
|
252
256
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
253
257
|
// Create session
|
|
254
|
-
await sessionManager.
|
|
258
|
+
await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
255
259
|
// Wait a bit to ensure no early exit
|
|
256
260
|
await new Promise(resolve => setTimeout(resolve, 600));
|
|
257
261
|
// Verify only one spawn attempt
|
|
@@ -275,7 +279,7 @@ describe('SessionManager', () => {
|
|
|
275
279
|
.mockReturnValueOnce(firstMockPty)
|
|
276
280
|
.mockReturnValueOnce(secondMockPty);
|
|
277
281
|
// Create session
|
|
278
|
-
const session = await sessionManager.
|
|
282
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
279
283
|
// Verify initial spawn
|
|
280
284
|
expect(spawn).toHaveBeenCalledTimes(1);
|
|
281
285
|
expect(spawn).toHaveBeenCalledWith('claude', ['--invalid-flag'], expect.objectContaining({ cwd: '/test/worktree' }));
|
|
@@ -302,7 +306,7 @@ describe('SessionManager', () => {
|
|
|
302
306
|
// Setup spawn mock
|
|
303
307
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
304
308
|
// Create session
|
|
305
|
-
await sessionManager.
|
|
309
|
+
await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
306
310
|
// Verify spawn was called with custom command
|
|
307
311
|
expect(spawn).toHaveBeenCalledWith('my-custom-claude', ['--config', '/path/to/config'], expect.objectContaining({
|
|
308
312
|
cwd: '/test/worktree',
|
|
@@ -321,7 +325,7 @@ describe('SessionManager', () => {
|
|
|
321
325
|
throw new Error('spawn failed');
|
|
322
326
|
});
|
|
323
327
|
// Expect createSessionWithPreset to throw
|
|
324
|
-
await expect(sessionManager.
|
|
328
|
+
await expect(Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'))).rejects.toThrow('spawn failed');
|
|
325
329
|
});
|
|
326
330
|
});
|
|
327
331
|
describe('session lifecycle', () => {
|
|
@@ -334,7 +338,7 @@ describe('SessionManager', () => {
|
|
|
334
338
|
});
|
|
335
339
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
336
340
|
// Create and destroy session
|
|
337
|
-
await sessionManager.
|
|
341
|
+
await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
338
342
|
sessionManager.destroySession('/test/worktree');
|
|
339
343
|
// Verify cleanup
|
|
340
344
|
expect(mockPty.kill).toHaveBeenCalled();
|
|
@@ -354,7 +358,7 @@ describe('SessionManager', () => {
|
|
|
354
358
|
exitedSession = session;
|
|
355
359
|
});
|
|
356
360
|
// Create session
|
|
357
|
-
const createdSession = await sessionManager.
|
|
361
|
+
const createdSession = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
358
362
|
// Simulate process exit after successful creation
|
|
359
363
|
setTimeout(() => {
|
|
360
364
|
mockPty.emit('exit', { exitCode: 0 });
|
|
@@ -365,7 +369,7 @@ describe('SessionManager', () => {
|
|
|
365
369
|
expect(sessionManager.getSession('/test/worktree')).toBeUndefined();
|
|
366
370
|
});
|
|
367
371
|
});
|
|
368
|
-
describe('
|
|
372
|
+
describe('createSessionWithDevcontainerEffect', () => {
|
|
369
373
|
beforeEach(() => {
|
|
370
374
|
// Reset shouldFail flag
|
|
371
375
|
const mockExec = vi.mocked(exec);
|
|
@@ -401,7 +405,7 @@ describe('SessionManager', () => {
|
|
|
401
405
|
upCommand: 'devcontainer up --workspace-folder .',
|
|
402
406
|
execCommand: 'devcontainer exec --workspace-folder .',
|
|
403
407
|
};
|
|
404
|
-
await sessionManager.
|
|
408
|
+
await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig));
|
|
405
409
|
// Verify spawn was called correctly which proves devcontainer up succeeded
|
|
406
410
|
// Verify spawn was called with devcontainer exec
|
|
407
411
|
expect(spawn).toHaveBeenCalledWith('devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude', '--resume'], expect.objectContaining({ cwd: '/test/worktree' }));
|
|
@@ -421,7 +425,7 @@ describe('SessionManager', () => {
|
|
|
421
425
|
upCommand: 'devcontainer up',
|
|
422
426
|
execCommand: 'devcontainer exec',
|
|
423
427
|
};
|
|
424
|
-
await sessionManager.
|
|
428
|
+
await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig, '2'));
|
|
425
429
|
// Verify correct preset was used
|
|
426
430
|
expect(configurationManager.getPresetById).toHaveBeenCalledWith('2');
|
|
427
431
|
expect(spawn).toHaveBeenCalledWith('devcontainer', ['exec', '--', 'claude', '--resume', '--dev'], expect.any(Object));
|
|
@@ -435,7 +439,7 @@ describe('SessionManager', () => {
|
|
|
435
439
|
upCommand: 'devcontainer up',
|
|
436
440
|
execCommand: 'devcontainer exec',
|
|
437
441
|
};
|
|
438
|
-
await expect(sessionManager.
|
|
442
|
+
await expect(Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig))).rejects.toThrow('Failed to start devcontainer: Container startup failed');
|
|
439
443
|
});
|
|
440
444
|
it('should return existing session if already created', async () => {
|
|
441
445
|
// Setup mock preset
|
|
@@ -451,8 +455,8 @@ describe('SessionManager', () => {
|
|
|
451
455
|
execCommand: 'devcontainer exec',
|
|
452
456
|
};
|
|
453
457
|
// Create session twice
|
|
454
|
-
const session1 = await sessionManager.
|
|
455
|
-
const session2 = await sessionManager.
|
|
458
|
+
const session1 = await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig));
|
|
459
|
+
const session2 = await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig));
|
|
456
460
|
// Should return the same session
|
|
457
461
|
expect(session1).toBe(session2);
|
|
458
462
|
// spawn should only be called once
|
|
@@ -473,7 +477,7 @@ describe('SessionManager', () => {
|
|
|
473
477
|
upCommand: 'devcontainer up --workspace-folder . --log-level debug',
|
|
474
478
|
execCommand: 'devcontainer exec --workspace-folder . --container-name mycontainer',
|
|
475
479
|
};
|
|
476
|
-
await sessionManager.
|
|
480
|
+
await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig));
|
|
477
481
|
// Verify spawn was called with properly parsed exec command
|
|
478
482
|
expect(spawn).toHaveBeenCalledWith('devcontainer', [
|
|
479
483
|
'exec',
|
|
@@ -510,10 +514,10 @@ describe('SessionManager', () => {
|
|
|
510
514
|
}
|
|
511
515
|
return {};
|
|
512
516
|
});
|
|
513
|
-
await sessionManager.
|
|
517
|
+
await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree2', {
|
|
514
518
|
upCommand: 'devcontainer up --workspace-folder .',
|
|
515
519
|
execCommand: 'devcontainer exec --workspace-folder .',
|
|
516
|
-
});
|
|
520
|
+
}));
|
|
517
521
|
// Should spawn with devcontainer exec command
|
|
518
522
|
expect(spawn).toHaveBeenCalledWith('devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude'], expect.objectContaining({
|
|
519
523
|
cwd: '/test/worktree2',
|
|
@@ -531,10 +535,10 @@ describe('SessionManager', () => {
|
|
|
531
535
|
}
|
|
532
536
|
return {};
|
|
533
537
|
});
|
|
534
|
-
await sessionManager.
|
|
538
|
+
await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', {
|
|
535
539
|
upCommand: 'devcontainer up --workspace-folder .',
|
|
536
540
|
execCommand: 'devcontainer exec --workspace-folder .',
|
|
537
|
-
}, 'custom-preset');
|
|
541
|
+
}, 'custom-preset'));
|
|
538
542
|
// Should call createSessionWithPreset internally
|
|
539
543
|
const session = sessionManager.getSession('/test/worktree');
|
|
540
544
|
expect(session).toBeDefined();
|
|
@@ -559,7 +563,7 @@ describe('SessionManager', () => {
|
|
|
559
563
|
upCommand: 'devcontainer up --workspace-folder /path/to/project',
|
|
560
564
|
execCommand: 'devcontainer exec --workspace-folder /path/to/project --user vscode',
|
|
561
565
|
};
|
|
562
|
-
await sessionManager.
|
|
566
|
+
await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', config));
|
|
563
567
|
expect(spawn).toHaveBeenCalledWith('devcontainer', [
|
|
564
568
|
'exec',
|
|
565
569
|
'--workspace-folder',
|
|
@@ -588,10 +592,10 @@ describe('SessionManager', () => {
|
|
|
588
592
|
command: 'claude',
|
|
589
593
|
args: ['-m', 'claude-3-opus'],
|
|
590
594
|
});
|
|
591
|
-
await sessionManager.
|
|
595
|
+
await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', {
|
|
592
596
|
upCommand: 'devcontainer up --workspace-folder .',
|
|
593
597
|
execCommand: 'devcontainer exec --workspace-folder .',
|
|
594
|
-
}, 'claude-with-args');
|
|
598
|
+
}, 'claude-with-args'));
|
|
595
599
|
expect(spawn).toHaveBeenCalledWith('devcontainer', [
|
|
596
600
|
'exec',
|
|
597
601
|
'--workspace-folder',
|
|
@@ -629,10 +633,10 @@ describe('SessionManager', () => {
|
|
|
629
633
|
vi.mocked(spawn)
|
|
630
634
|
.mockReturnValueOnce(firstMockPty)
|
|
631
635
|
.mockReturnValueOnce(secondMockPty);
|
|
632
|
-
const session = await sessionManager.
|
|
636
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', {
|
|
633
637
|
upCommand: 'devcontainer up --workspace-folder .',
|
|
634
638
|
execCommand: 'devcontainer exec --workspace-folder .',
|
|
635
|
-
});
|
|
639
|
+
}));
|
|
636
640
|
// Verify initial spawn
|
|
637
641
|
expect(spawn).toHaveBeenCalledTimes(1);
|
|
638
642
|
expect(spawn).toHaveBeenCalledWith('devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude', '--invalid-flag'], expect.objectContaining({ cwd: '/test/worktree' }));
|
|
@@ -675,10 +679,10 @@ describe('SessionManager', () => {
|
|
|
675
679
|
vi.mocked(spawn)
|
|
676
680
|
.mockReturnValueOnce(firstMockPty)
|
|
677
681
|
.mockReturnValueOnce(secondMockPty);
|
|
678
|
-
const session = await sessionManager.
|
|
682
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', {
|
|
679
683
|
upCommand: 'devcontainer up --workspace-folder .',
|
|
680
684
|
execCommand: 'devcontainer exec --workspace-folder .',
|
|
681
|
-
});
|
|
685
|
+
}));
|
|
682
686
|
// Verify initial spawn
|
|
683
687
|
expect(spawn).toHaveBeenCalledTimes(1);
|
|
684
688
|
expect(spawn).toHaveBeenCalledWith('devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude', '--bad-flag'], expect.objectContaining({ cwd: '/test/worktree' }));
|
|
@@ -7,8 +7,10 @@ export declare class ShortcutManager {
|
|
|
7
7
|
private isReservedKey;
|
|
8
8
|
saveShortcuts(shortcuts: ShortcutConfig): boolean;
|
|
9
9
|
getShortcuts(): ShortcutConfig;
|
|
10
|
+
private getRawShortcutCodes;
|
|
10
11
|
matchesShortcut(shortcutName: keyof ShortcutConfig, input: string, key: Key): boolean;
|
|
11
12
|
getShortcutDisplay(shortcutName: keyof ShortcutConfig): string;
|
|
12
13
|
getShortcutCode(shortcut: ShortcutKey): string | null;
|
|
14
|
+
matchesRawInput(shortcutName: keyof ShortcutConfig, input: string): boolean;
|
|
13
15
|
}
|
|
14
16
|
export declare const shortcutManager: ShortcutManager;
|
|
@@ -60,6 +60,51 @@ export class ShortcutManager {
|
|
|
60
60
|
getShortcuts() {
|
|
61
61
|
return configurationManager.getShortcuts();
|
|
62
62
|
}
|
|
63
|
+
getRawShortcutCodes(shortcut) {
|
|
64
|
+
const codes = new Set();
|
|
65
|
+
// Direct control-code form (e.g. Ctrl+E -> \u0005)
|
|
66
|
+
const controlCode = this.getShortcutCode(shortcut);
|
|
67
|
+
if (controlCode) {
|
|
68
|
+
codes.add(controlCode);
|
|
69
|
+
}
|
|
70
|
+
// Escape key in raw mode
|
|
71
|
+
if (shortcut.key === 'escape' &&
|
|
72
|
+
!shortcut.ctrl &&
|
|
73
|
+
!shortcut.alt &&
|
|
74
|
+
!shortcut.shift) {
|
|
75
|
+
codes.add('\u001b');
|
|
76
|
+
}
|
|
77
|
+
// Kitty/xterm extended keyboard sequences (CSI <code>;<mod>u)
|
|
78
|
+
if (shortcut.ctrl &&
|
|
79
|
+
!shortcut.alt &&
|
|
80
|
+
!shortcut.shift &&
|
|
81
|
+
shortcut.key.length === 1) {
|
|
82
|
+
const lower = shortcut.key.toLowerCase();
|
|
83
|
+
const upperCode = lower.toUpperCase().charCodeAt(0);
|
|
84
|
+
const lowerCode = lower.charCodeAt(0);
|
|
85
|
+
// Include the CSI u format (ESC[<code>;5u) used by Kitty/WezTerm for Ctrl+letters.
|
|
86
|
+
if (upperCode >= 32 && upperCode <= 126) {
|
|
87
|
+
codes.add(`\u001b[${upperCode};5u`);
|
|
88
|
+
}
|
|
89
|
+
if (lowerCode !== upperCode && lowerCode >= 32 && lowerCode <= 126) {
|
|
90
|
+
codes.add(`\u001b[${lowerCode};5u`);
|
|
91
|
+
}
|
|
92
|
+
// Tmux/xterm with modifyOtherKeys emit ESC[27;5;<code>~ for the same shortcut.
|
|
93
|
+
if (upperCode >= 32 && upperCode <= 126) {
|
|
94
|
+
codes.add(`\u001b[27;5;${upperCode}~`);
|
|
95
|
+
}
|
|
96
|
+
if (lowerCode !== upperCode && lowerCode >= 32 && lowerCode <= 126) {
|
|
97
|
+
codes.add(`\u001b[27;5;${lowerCode}~`);
|
|
98
|
+
}
|
|
99
|
+
// Some setups (issue #82/#107 repros) send ESC[1;5<letter>; include both upper/lower.
|
|
100
|
+
const upperKey = lower.toUpperCase();
|
|
101
|
+
codes.add(`\u001b[1;5${upperKey}`);
|
|
102
|
+
if (upperKey !== lower) {
|
|
103
|
+
codes.add(`\u001b[1;5${lower}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return Array.from(codes);
|
|
107
|
+
}
|
|
63
108
|
matchesShortcut(shortcutName, input, key) {
|
|
64
109
|
const shortcuts = configurationManager.getShortcuts();
|
|
65
110
|
const shortcut = shortcuts[shortcutName];
|
|
@@ -115,5 +160,13 @@ export class ShortcutManager {
|
|
|
115
160
|
}
|
|
116
161
|
return null;
|
|
117
162
|
}
|
|
163
|
+
matchesRawInput(shortcutName, input) {
|
|
164
|
+
const shortcuts = configurationManager.getShortcuts();
|
|
165
|
+
const shortcut = shortcuts[shortcutName];
|
|
166
|
+
if (!shortcut)
|
|
167
|
+
return false;
|
|
168
|
+
const codes = this.getRawShortcutCodes(shortcut);
|
|
169
|
+
return codes.some(code => input === code || input.includes(code));
|
|
170
|
+
}
|
|
118
171
|
}
|
|
119
172
|
export const shortcutManager = new ShortcutManager();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { shortcutManager } from './shortcutManager.js';
|
|
3
|
+
import { configurationManager } from './configurationManager.js';
|
|
4
|
+
describe('shortcutManager.matchesRawInput', () => {
|
|
5
|
+
const shortcuts = {
|
|
6
|
+
returnToMenu: { ctrl: true, key: 'e', alt: false, shift: false },
|
|
7
|
+
cancel: { ctrl: true, key: 'c', alt: false, shift: false },
|
|
8
|
+
};
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.spyOn(configurationManager, 'getShortcuts').mockReturnValue(shortcuts);
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.restoreAllMocks();
|
|
14
|
+
});
|
|
15
|
+
it('matches classic control code', () => {
|
|
16
|
+
expect(shortcutManager.matchesRawInput('returnToMenu', '\u0005')).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
it('matches CSI u sequence', () => {
|
|
19
|
+
expect(shortcutManager.matchesRawInput('returnToMenu', '\u001b[69;5u')).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
it('matches modifyOtherKeys sequence', () => {
|
|
22
|
+
expect(shortcutManager.matchesRawInput('returnToMenu', '\u001b[27;5;69~')).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
it('matches CSI 1;5<key>', () => {
|
|
25
|
+
expect(shortcutManager.matchesRawInput('returnToMenu', '\u001b[1;5E')).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
it('ignores unrelated input', () => {
|
|
28
|
+
expect(shortcutManager.matchesRawInput('returnToMenu', 'hello')).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
});
|