recomposable 1.1.3 → 1.1.4
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 +8 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +269 -27
- package/dist/index.js.map +1 -1
- package/dist/lib/docker.d.ts +5 -1
- package/dist/lib/docker.js +88 -4
- package/dist/lib/docker.js.map +1 -1
- package/dist/lib/renderer.js +59 -19
- package/dist/lib/renderer.js.map +1 -1
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +16 -0
- package/dist/lib/state.js.map +1 -1
- package/dist/lib/types.d.ts +19 -3
- package/package.json +1 -1
- package/screenshots/exec-view.png +0 -0
- package/screenshots/list-view.png +0 -0
- package/screenshots/logs-view.png +0 -0
package/README.md
CHANGED
|
@@ -48,6 +48,7 @@ recomposable
|
|
|
48
48
|
- **Docker Compose Watch** — toggle `docker compose watch` per service, with live output in the log panel
|
|
49
49
|
- **Dependency-aware rebuild** — rebuild a service then automatically restart all its transitive dependents in topological order
|
|
50
50
|
- **Container exec** — run commands inside any container, inline in the bottom panel (`e`) or full-screen (`x`), with `cd` support and command history
|
|
51
|
+
- **Worktree switching** — switch any service to run from a different git worktree (`t`), automatically rebuilds and starts in the target branch
|
|
51
52
|
- **Vim keybindings** — navigate with `j`/`k`, `G`/`gg`, and more
|
|
52
53
|
|
|
53
54
|
## Full Log View
|
|
@@ -68,6 +69,12 @@ Press `w` to toggle `docker compose watch` for a service. A cyan `W` indicator a
|
|
|
68
69
|
|
|
69
70
|
Press `d` to rebuild the selected service and then automatically restart all services that depend on it (transitively), in the correct topological order. Progress is shown step-by-step in the log panel. If the service has no dependents, falls back to a regular rebuild.
|
|
70
71
|
|
|
72
|
+
## Worktree Switching
|
|
73
|
+
|
|
74
|
+
Press `t` on any service to switch it to a different git worktree. A picker shows all available worktrees — navigate with `j`/`k`, confirm with `Enter`. The service is automatically stopped, rebuilt, and started from the target worktree's compose file. A `WORKTREE` column appears when services run from multiple branches, with non-main branches highlighted in yellow.
|
|
75
|
+
|
|
76
|
+
This is useful for end-to-end testing changes across branches without drowning in terminal tabs. Run your main stack on `main`, then switch individual services to feature branches to verify their behavior in the full environment. Particularly handy when letting Claude Code work in worktrees — switch the affected service, verify it end-to-end, and switch back, all from a single terminal.
|
|
77
|
+
|
|
71
78
|
## Adding Compose Files
|
|
72
79
|
|
|
73
80
|
Create a `recomposable.json` file in your project root:
|
|
@@ -136,6 +143,7 @@ recomposable -f docker-compose.yml -f docker-compose.prod.yml
|
|
|
136
143
|
| `x` | Full-screen exec mode |
|
|
137
144
|
| `n` | Toggle no-cache mode (rebuild with `--no-cache` + `--force-recreate`) |
|
|
138
145
|
| `f` / `Enter` | Full-screen log view for selected service |
|
|
146
|
+
| `t` | Switch service to a different git worktree |
|
|
139
147
|
| `l` | Toggle inline log panel |
|
|
140
148
|
| `/` | Search in inline log panel |
|
|
141
149
|
| `G` | Jump to bottom |
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import type { Config, AppState, ServiceGroup } from './lib/types';
|
|
2
|
+
import type { Config, AppState, ServiceGroup, GitWorktree } from './lib/types';
|
|
3
3
|
export interface ModuleState {
|
|
4
4
|
logScanActive: boolean;
|
|
5
5
|
statsPollActive: boolean;
|
|
@@ -11,6 +11,7 @@ export declare function createModuleState(): ModuleState;
|
|
|
11
11
|
export declare function loadConfig(): Config;
|
|
12
12
|
export declare function discoverServices(config: Config): ServiceGroup[];
|
|
13
13
|
export declare function pollStatuses(state: AppState): void;
|
|
14
|
+
export declare function detectMultipleWorktrees(state: AppState): void;
|
|
14
15
|
export declare function pollLogCounts(state: AppState): void;
|
|
15
16
|
export declare function pollContainerStats(state: AppState): void;
|
|
16
17
|
export declare function render(state: AppState): void;
|
|
@@ -21,6 +22,9 @@ export declare function doRebuild(state: AppState): void;
|
|
|
21
22
|
export declare function doRestart(state: AppState): void;
|
|
22
23
|
export declare function doStop(state: AppState): void;
|
|
23
24
|
export declare function doStart(state: AppState): void;
|
|
25
|
+
export declare function mapComposeFileToWorktree(composeFile: string, targetWorktreePath: string): string | null;
|
|
26
|
+
export declare function openWorktreePicker(state: AppState): void;
|
|
27
|
+
export declare function doWorktreeSwitch(state: AppState, targetWorktree: GitWorktree): void;
|
|
24
28
|
export declare function doWatch(state: AppState): void;
|
|
25
29
|
export declare function initDepGraphs(state: AppState): void;
|
|
26
30
|
export declare function doCascadeRebuild(state: AppState): void;
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ exports.createModuleState = createModuleState;
|
|
|
8
8
|
exports.loadConfig = loadConfig;
|
|
9
9
|
exports.discoverServices = discoverServices;
|
|
10
10
|
exports.pollStatuses = pollStatuses;
|
|
11
|
+
exports.detectMultipleWorktrees = detectMultipleWorktrees;
|
|
11
12
|
exports.pollLogCounts = pollLogCounts;
|
|
12
13
|
exports.pollContainerStats = pollContainerStats;
|
|
13
14
|
exports.render = render;
|
|
@@ -18,6 +19,9 @@ exports.doRebuild = doRebuild;
|
|
|
18
19
|
exports.doRestart = doRestart;
|
|
19
20
|
exports.doStop = doStop;
|
|
20
21
|
exports.doStart = doStart;
|
|
22
|
+
exports.mapComposeFileToWorktree = mapComposeFileToWorktree;
|
|
23
|
+
exports.openWorktreePicker = openWorktreePicker;
|
|
24
|
+
exports.doWorktreeSwitch = doWorktreeSwitch;
|
|
21
25
|
exports.doWatch = doWatch;
|
|
22
26
|
exports.initDepGraphs = initDepGraphs;
|
|
23
27
|
exports.doCascadeRebuild = doCascadeRebuild;
|
|
@@ -60,7 +64,7 @@ function loadConfig() {
|
|
|
60
64
|
composeFiles: [],
|
|
61
65
|
pollInterval: 3000,
|
|
62
66
|
logTailLines: 100,
|
|
63
|
-
logScanPatterns: ['WRN]', 'ERR]'],
|
|
67
|
+
logScanPatterns: [['WRN]', 'WARNING'], ['ERR]', 'ERROR']],
|
|
64
68
|
logScanLines: 1000,
|
|
65
69
|
logScanInterval: 10000,
|
|
66
70
|
statsInterval: 5000,
|
|
@@ -78,7 +82,7 @@ function loadConfig() {
|
|
|
78
82
|
if (Array.isArray(raw.composeFiles) && raw.composeFiles.every((f) => typeof f === 'string')) {
|
|
79
83
|
defaults.composeFiles = raw.composeFiles;
|
|
80
84
|
}
|
|
81
|
-
if (Array.isArray(raw.logScanPatterns) && raw.logScanPatterns.every((p) => typeof p === 'string')) {
|
|
85
|
+
if (Array.isArray(raw.logScanPatterns) && raw.logScanPatterns.every((p) => typeof p === 'string' || (Array.isArray(p) && p.length > 0 && p.every((s) => typeof s === 'string')))) {
|
|
82
86
|
defaults.logScanPatterns = raw.logScanPatterns;
|
|
83
87
|
}
|
|
84
88
|
const numericFields = [
|
|
@@ -138,14 +142,41 @@ function discoverServices(config) {
|
|
|
138
142
|
}
|
|
139
143
|
// --- Status Polling ---
|
|
140
144
|
function pollStatuses(state) {
|
|
145
|
+
// Collect services by their effective file (may differ from group file due to worktree overrides)
|
|
146
|
+
const fileToServices = new Map();
|
|
141
147
|
for (const group of state.groups) {
|
|
142
148
|
if (group.error)
|
|
143
149
|
continue;
|
|
144
|
-
const
|
|
150
|
+
for (const service of group.services) {
|
|
151
|
+
const sk = (0, state_1.statusKey)(group.file, service);
|
|
152
|
+
const file = (0, state_1.getEffectiveFile)(state, group.file, service);
|
|
153
|
+
if (!fileToServices.has(file))
|
|
154
|
+
fileToServices.set(file, []);
|
|
155
|
+
fileToServices.get(file).push({ sk, service });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
for (const [file, services] of fileToServices) {
|
|
159
|
+
const statuses = (0, docker_1.getStatuses)(file);
|
|
160
|
+
const serviceSet = new Set(services.map(s => s.service));
|
|
145
161
|
for (const [svc, st] of statuses) {
|
|
146
|
-
|
|
162
|
+
if (serviceSet.has(svc)) {
|
|
163
|
+
// Store under the original statusKey (group.file based)
|
|
164
|
+
const match = services.find(s => s.service === svc);
|
|
165
|
+
if (match)
|
|
166
|
+
state.statuses.set(match.sk, st);
|
|
167
|
+
}
|
|
147
168
|
}
|
|
148
169
|
}
|
|
170
|
+
detectMultipleWorktrees(state);
|
|
171
|
+
}
|
|
172
|
+
function detectMultipleWorktrees(state) {
|
|
173
|
+
const worktrees = new Set();
|
|
174
|
+
for (const st of state.statuses.values()) {
|
|
175
|
+
if (st.state === 'running' && st.worktree) {
|
|
176
|
+
worktrees.add(st.worktree);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
state.showWorktreeColumn = worktrees.size > 1;
|
|
149
180
|
}
|
|
150
181
|
// --- Log Pattern Scanning ---
|
|
151
182
|
function pollLogCounts(state) {
|
|
@@ -178,14 +209,18 @@ function pollLogCounts(state) {
|
|
|
178
209
|
child.stderr.on('data', (d) => { output += d.toString(); });
|
|
179
210
|
child.on('close', () => {
|
|
180
211
|
const counts = new Map();
|
|
181
|
-
for (const
|
|
212
|
+
for (const entry of scanPatterns) {
|
|
213
|
+
const group = Array.isArray(entry) ? entry : [entry];
|
|
214
|
+
const key = group[0];
|
|
182
215
|
let count = 0;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
216
|
+
for (const pattern of group) {
|
|
217
|
+
let idx = 0;
|
|
218
|
+
while ((idx = output.indexOf(pattern, idx)) !== -1) {
|
|
219
|
+
count++;
|
|
220
|
+
idx += pattern.length;
|
|
221
|
+
}
|
|
187
222
|
}
|
|
188
|
-
counts.set(
|
|
223
|
+
counts.set(key, count);
|
|
189
224
|
}
|
|
190
225
|
state.logCounts.set(sk, counts);
|
|
191
226
|
remaining--;
|
|
@@ -341,9 +376,10 @@ function updateSelectedLogs(state) {
|
|
|
341
376
|
if (state.bottomLogLines.has(sk))
|
|
342
377
|
return;
|
|
343
378
|
state.bottomLogLines.set(sk, { action: 'logs', service: entry.service, lines: [] });
|
|
379
|
+
const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
|
|
344
380
|
moduleState.logFetchTimer = setTimeout(() => {
|
|
345
381
|
moduleState.logFetchTimer = null;
|
|
346
|
-
startBottomLogTail(state, sk,
|
|
382
|
+
startBottomLogTail(state, sk, effectiveFile, entry.service);
|
|
347
383
|
}, 500);
|
|
348
384
|
}
|
|
349
385
|
function startBottomLogTail(state, sk, file, service) {
|
|
@@ -384,11 +420,12 @@ function doRebuild(state) {
|
|
|
384
420
|
const sk = (0, state_1.statusKey)(entry.file, entry.service);
|
|
385
421
|
if (state.rebuilding.has(sk))
|
|
386
422
|
return;
|
|
423
|
+
const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
|
|
387
424
|
if (state.bottomLogTails.has(sk)) {
|
|
388
425
|
state.bottomLogTails.get(sk).kill('SIGTERM');
|
|
389
426
|
state.bottomLogTails.delete(sk);
|
|
390
427
|
}
|
|
391
|
-
const child = (0, docker_1.rebuildService)(
|
|
428
|
+
const child = (0, docker_1.rebuildService)(effectiveFile, entry.service, { noCache: state.noCache, noDeps: state.noDeps });
|
|
392
429
|
state.rebuilding.set(sk, child);
|
|
393
430
|
state.bottomLogLines.set(sk, { action: 'rebuilding', service: entry.service, lines: [] });
|
|
394
431
|
let lineBuf = '';
|
|
@@ -432,7 +469,7 @@ function doRebuild(state) {
|
|
|
432
469
|
if (state.logBuildKey !== sk)
|
|
433
470
|
info.lines = [];
|
|
434
471
|
}
|
|
435
|
-
startBottomLogTail(state, sk,
|
|
472
|
+
startBottomLogTail(state, sk, effectiveFile, entry.service);
|
|
436
473
|
if (state.mode === state_1.MODE.LIST)
|
|
437
474
|
render(state);
|
|
438
475
|
});
|
|
@@ -444,11 +481,12 @@ function doRestart(state) {
|
|
|
444
481
|
const sk = (0, state_1.statusKey)(entry.file, entry.service);
|
|
445
482
|
if (state.restarting.has(sk) || state.rebuilding.has(sk))
|
|
446
483
|
return;
|
|
484
|
+
const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
|
|
447
485
|
if (state.bottomLogTails.has(sk)) {
|
|
448
486
|
state.bottomLogTails.get(sk).kill('SIGTERM');
|
|
449
487
|
state.bottomLogTails.delete(sk);
|
|
450
488
|
}
|
|
451
|
-
const child = (0, docker_1.restartService)(
|
|
489
|
+
const child = (0, docker_1.restartService)(effectiveFile, entry.service);
|
|
452
490
|
state.restarting.set(sk, child);
|
|
453
491
|
state.bottomLogLines.set(sk, { action: 'restarting', service: entry.service, lines: [] });
|
|
454
492
|
render(state);
|
|
@@ -469,7 +507,7 @@ function doRestart(state) {
|
|
|
469
507
|
info.action = 'started';
|
|
470
508
|
info.lines = [];
|
|
471
509
|
}
|
|
472
|
-
startBottomLogTail(state, sk,
|
|
510
|
+
startBottomLogTail(state, sk, effectiveFile, entry.service);
|
|
473
511
|
if (state.mode === state_1.MODE.LIST)
|
|
474
512
|
render(state);
|
|
475
513
|
});
|
|
@@ -488,7 +526,8 @@ function doStop(state) {
|
|
|
488
526
|
state.bottomLogTails.get(sk).kill('SIGTERM');
|
|
489
527
|
state.bottomLogTails.delete(sk);
|
|
490
528
|
}
|
|
491
|
-
const
|
|
529
|
+
const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
|
|
530
|
+
const child = (0, docker_1.stopService)(effectiveFile, entry.service);
|
|
492
531
|
state.stopping.set(sk, child);
|
|
493
532
|
state.bottomLogLines.set(sk, { action: 'stopping', service: entry.service, lines: [] });
|
|
494
533
|
render(state);
|
|
@@ -517,7 +556,8 @@ function doStart(state) {
|
|
|
517
556
|
const st = state.statuses.get(sk);
|
|
518
557
|
if (st && st.state === 'running')
|
|
519
558
|
return;
|
|
520
|
-
const
|
|
559
|
+
const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
|
|
560
|
+
const child = (0, docker_1.startService)(effectiveFile, entry.service);
|
|
521
561
|
state.starting.set(sk, child);
|
|
522
562
|
state.bottomLogLines.set(sk, { action: 'starting', service: entry.service, lines: [] });
|
|
523
563
|
render(state);
|
|
@@ -536,11 +576,164 @@ function doStart(state) {
|
|
|
536
576
|
info.action = 'started';
|
|
537
577
|
info.lines = [];
|
|
538
578
|
}
|
|
539
|
-
startBottomLogTail(state, sk,
|
|
579
|
+
startBottomLogTail(state, sk, effectiveFile, entry.service);
|
|
540
580
|
if (state.mode === state_1.MODE.LIST)
|
|
541
581
|
render(state);
|
|
542
582
|
});
|
|
543
583
|
}
|
|
584
|
+
// --- Worktree Switching ---
|
|
585
|
+
function mapComposeFileToWorktree(composeFile, targetWorktreePath) {
|
|
586
|
+
const resolved = path_1.default.resolve(composeFile);
|
|
587
|
+
const dir = path_1.default.dirname(resolved);
|
|
588
|
+
const gitRoot = (0, docker_1.getGitRoot)(dir);
|
|
589
|
+
if (!gitRoot)
|
|
590
|
+
return null;
|
|
591
|
+
const relPath = path_1.default.relative(gitRoot, resolved);
|
|
592
|
+
const newFile = path_1.default.join(targetWorktreePath, relPath);
|
|
593
|
+
try {
|
|
594
|
+
fs_1.default.accessSync(newFile);
|
|
595
|
+
return newFile;
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
function openWorktreePicker(state) {
|
|
602
|
+
const entry = (0, state_1.selectedEntry)(state);
|
|
603
|
+
if (!entry)
|
|
604
|
+
return;
|
|
605
|
+
const sk = (0, state_1.statusKey)(entry.file, entry.service);
|
|
606
|
+
if (state.rebuilding.has(sk) || state.restarting.has(sk) || state.stopping.has(sk) || state.starting.has(sk) || state.cascading.has(sk))
|
|
607
|
+
return;
|
|
608
|
+
const composeDir = path_1.default.dirname(path_1.default.resolve(entry.file));
|
|
609
|
+
const worktrees = (0, docker_1.listGitWorktrees)(composeDir);
|
|
610
|
+
if (worktrees.length <= 1) {
|
|
611
|
+
state.bottomLogLines.set(sk, { action: 'switch_failed', service: entry.service, lines: ['no other worktrees available — use `git worktree add` to create one'] });
|
|
612
|
+
state.showBottomLogs = true;
|
|
613
|
+
render(state);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const gitRoot = (0, docker_1.getGitRoot)(composeDir);
|
|
617
|
+
state.worktreePickerEntries = worktrees;
|
|
618
|
+
state.worktreePickerActive = true;
|
|
619
|
+
state.worktreePickerCurrentPath = gitRoot;
|
|
620
|
+
// Pre-select first non-current worktree
|
|
621
|
+
const currentIdx = gitRoot ? worktrees.findIndex(w => w.path === gitRoot) : -1;
|
|
622
|
+
const firstOther = worktrees.findIndex((_, i) => i !== currentIdx);
|
|
623
|
+
state.worktreePickerCursor = firstOther >= 0 ? firstOther : 0;
|
|
624
|
+
state.showBottomLogs = true;
|
|
625
|
+
render(state);
|
|
626
|
+
}
|
|
627
|
+
function doWorktreeSwitch(state, targetWorktree) {
|
|
628
|
+
const entry = (0, state_1.selectedEntry)(state);
|
|
629
|
+
if (!entry)
|
|
630
|
+
return;
|
|
631
|
+
const service = entry.service;
|
|
632
|
+
const sk = (0, state_1.statusKey)(entry.file, service);
|
|
633
|
+
// Close picker
|
|
634
|
+
state.worktreePickerActive = false;
|
|
635
|
+
state.worktreePickerEntries = [];
|
|
636
|
+
state.worktreePickerCursor = 0;
|
|
637
|
+
// Compute new file from the original group file
|
|
638
|
+
const newFile = mapComposeFileToWorktree(entry.file, targetWorktree.path);
|
|
639
|
+
if (!newFile) {
|
|
640
|
+
state.bottomLogLines.set(sk, {
|
|
641
|
+
action: 'switch_failed', service,
|
|
642
|
+
lines: [`compose file not found in worktree "${targetWorktree.branch}" (${targetWorktree.path})`],
|
|
643
|
+
});
|
|
644
|
+
render(state);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
// If target is the same as current effective file, nothing to do
|
|
648
|
+
const currentEffective = (0, state_1.getEffectiveFile)(state, entry.file, service);
|
|
649
|
+
if (newFile === currentEffective) {
|
|
650
|
+
render(state);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
// Validate service exists in target compose file
|
|
654
|
+
if (!(0, docker_1.validateServiceInComposeFile)(newFile, service)) {
|
|
655
|
+
state.bottomLogLines.set(sk, {
|
|
656
|
+
action: 'switch_failed', service,
|
|
657
|
+
lines: [`service "${service}" not found in ${path_1.default.basename(newFile)} on branch "${targetWorktree.branch}"`],
|
|
658
|
+
});
|
|
659
|
+
render(state);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
// Show switching progress
|
|
663
|
+
state.bottomLogLines.set(sk, { action: 'switching', service, lines: [`switching to worktree "${targetWorktree.branch}"...`] });
|
|
664
|
+
render(state);
|
|
665
|
+
const performSwitch = () => {
|
|
666
|
+
// Store the worktree override (or remove if switching back to original)
|
|
667
|
+
if (newFile === entry.file) {
|
|
668
|
+
state.worktreeOverrides.delete(sk);
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
state.worktreeOverrides.set(sk, newFile);
|
|
672
|
+
}
|
|
673
|
+
// Update bottomLogLines to show rebuild
|
|
674
|
+
state.bottomLogLines.set(sk, { action: 'switching', service, lines: [`rebuilding in worktree "${targetWorktree.branch}"...`] });
|
|
675
|
+
// Rebuild in new worktree
|
|
676
|
+
const child = (0, docker_1.rebuildService)(newFile, service, { noCache: state.noCache, noDeps: state.noDeps });
|
|
677
|
+
state.rebuilding.set(sk, child);
|
|
678
|
+
let lineBuf = '';
|
|
679
|
+
const onData = (data) => {
|
|
680
|
+
const info = state.bottomLogLines.get(sk);
|
|
681
|
+
if (!info)
|
|
682
|
+
return;
|
|
683
|
+
lineBuf += data.toString();
|
|
684
|
+
const parts = lineBuf.split(/\r?\n|\r/);
|
|
685
|
+
lineBuf = parts.pop();
|
|
686
|
+
const newLines = parts.filter(l => l.trim().length > 0).map(stripAnsi).filter(Boolean);
|
|
687
|
+
if (newLines.length === 0)
|
|
688
|
+
return;
|
|
689
|
+
info.lines.push(...newLines);
|
|
690
|
+
if (state.mode === state_1.MODE.LIST)
|
|
691
|
+
throttledRender(state);
|
|
692
|
+
};
|
|
693
|
+
child.stdout.on('data', onData);
|
|
694
|
+
child.stderr.on('data', onData);
|
|
695
|
+
render(state);
|
|
696
|
+
child.on('close', (code) => {
|
|
697
|
+
state.rebuilding.delete(sk);
|
|
698
|
+
state.containerStatsHistory.delete(sk);
|
|
699
|
+
state.containerStats.delete(sk);
|
|
700
|
+
pollStatuses(state);
|
|
701
|
+
const info = state.bottomLogLines.get(sk);
|
|
702
|
+
if (code !== 0 && code !== null) {
|
|
703
|
+
if (info)
|
|
704
|
+
info.action = 'build_failed';
|
|
705
|
+
if (state.mode === state_1.MODE.LIST)
|
|
706
|
+
render(state);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
if (info) {
|
|
710
|
+
info.action = 'started';
|
|
711
|
+
info.lines = [];
|
|
712
|
+
}
|
|
713
|
+
startBottomLogTail(state, sk, newFile, service);
|
|
714
|
+
if (state.mode === state_1.MODE.LIST)
|
|
715
|
+
render(state);
|
|
716
|
+
});
|
|
717
|
+
};
|
|
718
|
+
// If service is running, stop it first (using current effective file)
|
|
719
|
+
const st = state.statuses.get(sk);
|
|
720
|
+
if (st && st.state === 'running') {
|
|
721
|
+
if (state.bottomLogTails.has(sk)) {
|
|
722
|
+
state.bottomLogTails.get(sk).kill('SIGTERM');
|
|
723
|
+
state.bottomLogTails.delete(sk);
|
|
724
|
+
}
|
|
725
|
+
const stopChild = (0, docker_1.stopService)(currentEffective, service);
|
|
726
|
+
state.stopping.set(sk, stopChild);
|
|
727
|
+
render(state);
|
|
728
|
+
stopChild.on('close', () => {
|
|
729
|
+
state.stopping.delete(sk);
|
|
730
|
+
performSwitch();
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
performSwitch();
|
|
735
|
+
}
|
|
736
|
+
}
|
|
544
737
|
// --- Watch ---
|
|
545
738
|
function doWatch(state) {
|
|
546
739
|
const entry = (0, state_1.selectedEntry)(state);
|
|
@@ -568,7 +761,8 @@ function doWatch(state) {
|
|
|
568
761
|
render(state);
|
|
569
762
|
return;
|
|
570
763
|
}
|
|
571
|
-
const
|
|
764
|
+
const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
|
|
765
|
+
const child = (0, docker_1.watchService)(effectiveFile, entry.service);
|
|
572
766
|
state.watching.set(sk, child);
|
|
573
767
|
state.bottomLogLines.set(sk, { action: 'watching', service: entry.service, lines: [] });
|
|
574
768
|
state.showBottomLogs = true;
|
|
@@ -666,9 +860,21 @@ function doCascadeRebuild(state) {
|
|
|
666
860
|
const sk = (0, state_1.statusKey)(entry.file, entry.service);
|
|
667
861
|
if (state.rebuilding.has(sk) || state.cascading.has(sk))
|
|
668
862
|
return;
|
|
669
|
-
const
|
|
863
|
+
const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
|
|
864
|
+
let graph = state.depGraphs.get(effectiveFile);
|
|
865
|
+
if (!graph) {
|
|
866
|
+
// Try to parse dep graph for the effective file (may differ from original)
|
|
867
|
+
try {
|
|
868
|
+
graph = (0, docker_1.parseDependencyGraph)(effectiveFile);
|
|
869
|
+
state.depGraphs.set(effectiveFile, graph);
|
|
870
|
+
}
|
|
871
|
+
catch {
|
|
872
|
+
// No graph available, fall back to regular rebuild
|
|
873
|
+
doRebuild(state);
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
670
877
|
if (!graph) {
|
|
671
|
-
// No graph available, fall back to regular rebuild
|
|
672
878
|
doRebuild(state);
|
|
673
879
|
return;
|
|
674
880
|
}
|
|
@@ -687,7 +893,7 @@ function doCascadeRebuild(state) {
|
|
|
687
893
|
state.cascading.set(sk, cascade);
|
|
688
894
|
state.bottomLogLines.set(sk, { action: 'cascading', service: entry.service, lines: [] });
|
|
689
895
|
state.showBottomLogs = true;
|
|
690
|
-
executeCascadeStep(state,
|
|
896
|
+
executeCascadeStep(state, effectiveFile, sk, cascade);
|
|
691
897
|
render(state);
|
|
692
898
|
}
|
|
693
899
|
function executeCascadeStep(state, file, sk, cascade) {
|
|
@@ -947,7 +1153,8 @@ function enterLogs(state) {
|
|
|
947
1153
|
}
|
|
948
1154
|
else {
|
|
949
1155
|
state.logBuildKey = null;
|
|
950
|
-
const
|
|
1156
|
+
const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
|
|
1157
|
+
const child = (0, docker_1.tailLogs)(effectiveFile, entry.service, 200);
|
|
951
1158
|
state.logChild = child;
|
|
952
1159
|
let lineBuf = '';
|
|
953
1160
|
const onData = (data) => {
|
|
@@ -1013,7 +1220,8 @@ function loadMoreLogHistory(state) {
|
|
|
1013
1220
|
nextTail = 'all';
|
|
1014
1221
|
state.logHistoryLoading = true;
|
|
1015
1222
|
const snapshotLen = state.logLines.length;
|
|
1016
|
-
const
|
|
1223
|
+
const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
|
|
1224
|
+
const child = (0, docker_1.fetchServiceLogs)(effectiveFile, entry.service, nextTail);
|
|
1017
1225
|
state.logHistoryChild = child;
|
|
1018
1226
|
let output = '';
|
|
1019
1227
|
child.stdout.on('data', (d) => { output += d.toString(); });
|
|
@@ -1110,7 +1318,8 @@ function executeBottomSearch(state) {
|
|
|
1110
1318
|
state.bottomSearchLoading = true;
|
|
1111
1319
|
state.bottomSearchTotalMatches = 0;
|
|
1112
1320
|
render(state);
|
|
1113
|
-
const
|
|
1321
|
+
const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
|
|
1322
|
+
const child = (0, docker_1.fetchServiceLogs)(effectiveFile, entry.service, 'all');
|
|
1114
1323
|
state.bottomSearchChild = child;
|
|
1115
1324
|
let output = '';
|
|
1116
1325
|
child.stdout.on('data', (d) => { output += d.toString(); });
|
|
@@ -1338,6 +1547,34 @@ function handleKeypress(state, key) {
|
|
|
1338
1547
|
}
|
|
1339
1548
|
return;
|
|
1340
1549
|
}
|
|
1550
|
+
// LIST mode - worktree picker
|
|
1551
|
+
if (state.worktreePickerActive) {
|
|
1552
|
+
if (key === '\x1b') {
|
|
1553
|
+
state.worktreePickerActive = false;
|
|
1554
|
+
state.worktreePickerEntries = [];
|
|
1555
|
+
state.worktreePickerCursor = 0;
|
|
1556
|
+
state.worktreePickerCurrentPath = null;
|
|
1557
|
+
render(state);
|
|
1558
|
+
}
|
|
1559
|
+
else if (key === '\r') {
|
|
1560
|
+
const target = state.worktreePickerEntries[state.worktreePickerCursor];
|
|
1561
|
+
if (target)
|
|
1562
|
+
doWorktreeSwitch(state, target);
|
|
1563
|
+
}
|
|
1564
|
+
else if (key === 'j' || key === '\x1b[B') {
|
|
1565
|
+
state.worktreePickerCursor = Math.min(state.worktreePickerEntries.length - 1, state.worktreePickerCursor + 1);
|
|
1566
|
+
render(state);
|
|
1567
|
+
}
|
|
1568
|
+
else if (key === 'k' || key === '\x1b[A') {
|
|
1569
|
+
state.worktreePickerCursor = Math.max(0, state.worktreePickerCursor - 1);
|
|
1570
|
+
render(state);
|
|
1571
|
+
}
|
|
1572
|
+
else if (key === 'G') {
|
|
1573
|
+
state.worktreePickerCursor = state.worktreePickerEntries.length - 1;
|
|
1574
|
+
render(state);
|
|
1575
|
+
}
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1341
1578
|
// LIST mode - inline exec input
|
|
1342
1579
|
if (state.execActive) {
|
|
1343
1580
|
if (key === '\x1b') {
|
|
@@ -1483,6 +1720,9 @@ function handleKeypress(state, key) {
|
|
|
1483
1720
|
state.showBottomLogs = !state.showBottomLogs;
|
|
1484
1721
|
render(state);
|
|
1485
1722
|
break;
|
|
1723
|
+
case 't':
|
|
1724
|
+
openWorktreePicker(state);
|
|
1725
|
+
break;
|
|
1486
1726
|
case 'q':
|
|
1487
1727
|
cleanup(state);
|
|
1488
1728
|
process.exit(0);
|
|
@@ -1536,7 +1776,7 @@ function createInputHandler(state) {
|
|
|
1536
1776
|
}
|
|
1537
1777
|
const ch = buf[0];
|
|
1538
1778
|
buf = buf.slice(1);
|
|
1539
|
-
if (state.logSearchActive || state.bottomSearchActive || state.mode === state_1.MODE.EXEC || state.execActive) {
|
|
1779
|
+
if (state.logSearchActive || state.bottomSearchActive || state.worktreePickerActive || state.mode === state_1.MODE.EXEC || state.execActive) {
|
|
1540
1780
|
handleKeypress(state, ch);
|
|
1541
1781
|
continue;
|
|
1542
1782
|
}
|
|
@@ -1636,7 +1876,7 @@ function cleanup(state) {
|
|
|
1636
1876
|
if (state.statsTimer) {
|
|
1637
1877
|
clearInterval(state.statsTimer);
|
|
1638
1878
|
}
|
|
1639
|
-
process.stdout.write('\x1b[r' + (0, renderer_1.showCursor)() + '\x1b[0m');
|
|
1879
|
+
process.stdout.write('\x1b[r' + (0, renderer_1.showCursor)() + '\x1b[0m\x1b[?1049l');
|
|
1640
1880
|
}
|
|
1641
1881
|
// Expose for testing
|
|
1642
1882
|
function _getModuleState() {
|
|
@@ -1647,6 +1887,8 @@ function _setModuleState(ms) {
|
|
|
1647
1887
|
}
|
|
1648
1888
|
// --- Main ---
|
|
1649
1889
|
function main() {
|
|
1890
|
+
// Enter alternate screen buffer so pre-launch output (e.g. npx install) is hidden
|
|
1891
|
+
process.stdout.write('\x1b[?1049h');
|
|
1650
1892
|
const config = loadConfig();
|
|
1651
1893
|
const state = (0, state_1.createState)(config);
|
|
1652
1894
|
state.groups = discoverServices(config);
|