recomposable 1.1.2 → 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 +9 -1
- package/dist/index.js +607 -80
- package/dist/index.js.map +1 -1
- package/dist/lib/docker.d.ts +7 -2
- package/dist/lib/docker.js +124 -13
- package/dist/lib/docker.js.map +1 -1
- package/dist/lib/renderer.d.ts +4 -1
- package/dist/lib/renderer.js +272 -130
- package/dist/lib/renderer.js.map +1 -1
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +27 -0
- package/dist/lib/state.js.map +1 -1
- package/dist/lib/types.d.ts +33 -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/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,18 +19,25 @@ 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;
|
|
24
28
|
exports.enterExecInline = enterExecInline;
|
|
25
29
|
exports.enterExec = enterExec;
|
|
26
30
|
exports.exitExec = exitExec;
|
|
31
|
+
exports.shellEscape = shellEscape;
|
|
27
32
|
exports.runExecCommand = runExecCommand;
|
|
28
33
|
exports.enterLogs = enterLogs;
|
|
29
34
|
exports.exitLogs = exitLogs;
|
|
35
|
+
exports.loadMoreLogHistory = loadMoreLogHistory;
|
|
30
36
|
exports.executeLogSearch = executeLogSearch;
|
|
31
37
|
exports.jumpToNextMatch = jumpToNextMatch;
|
|
32
38
|
exports.jumpToPrevMatch = jumpToPrevMatch;
|
|
39
|
+
exports.executeBottomSearch = executeBottomSearch;
|
|
40
|
+
exports.clearBottomSearch = clearBottomSearch;
|
|
33
41
|
exports.handleKeypress = handleKeypress;
|
|
34
42
|
exports.createInputHandler = createInputHandler;
|
|
35
43
|
exports.cleanup = cleanup;
|
|
@@ -56,7 +64,7 @@ function loadConfig() {
|
|
|
56
64
|
composeFiles: [],
|
|
57
65
|
pollInterval: 3000,
|
|
58
66
|
logTailLines: 100,
|
|
59
|
-
logScanPatterns: ['WRN]', 'ERR]'],
|
|
67
|
+
logScanPatterns: [['WRN]', 'WARNING'], ['ERR]', 'ERROR']],
|
|
60
68
|
logScanLines: 1000,
|
|
61
69
|
logScanInterval: 10000,
|
|
62
70
|
statsInterval: 5000,
|
|
@@ -69,7 +77,33 @@ function loadConfig() {
|
|
|
69
77
|
};
|
|
70
78
|
const configPath = path_1.default.join(process.cwd(), 'recomposable.json');
|
|
71
79
|
if (fs_1.default.existsSync(configPath)) {
|
|
72
|
-
|
|
80
|
+
const raw = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
|
|
81
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
82
|
+
if (Array.isArray(raw.composeFiles) && raw.composeFiles.every((f) => typeof f === 'string')) {
|
|
83
|
+
defaults.composeFiles = raw.composeFiles;
|
|
84
|
+
}
|
|
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')))) {
|
|
86
|
+
defaults.logScanPatterns = raw.logScanPatterns;
|
|
87
|
+
}
|
|
88
|
+
const numericFields = [
|
|
89
|
+
{ key: 'pollInterval', min: 500, max: 300000 },
|
|
90
|
+
{ key: 'logTailLines', min: 1, max: 50000 },
|
|
91
|
+
{ key: 'logScanLines', min: 1, max: 50000 },
|
|
92
|
+
{ key: 'logScanInterval', min: 1000, max: 600000 },
|
|
93
|
+
{ key: 'statsInterval', min: 1000, max: 600000 },
|
|
94
|
+
{ key: 'statsBufferSize', min: 1, max: 100 },
|
|
95
|
+
{ key: 'bottomLogCount', min: 1, max: 200 },
|
|
96
|
+
{ key: 'cpuWarnThreshold', min: 0, max: 10000 },
|
|
97
|
+
{ key: 'cpuDangerThreshold', min: 0, max: 10000 },
|
|
98
|
+
{ key: 'memWarnThreshold', min: 0, max: 1048576 },
|
|
99
|
+
{ key: 'memDangerThreshold', min: 0, max: 1048576 },
|
|
100
|
+
];
|
|
101
|
+
for (const { key, min, max } of numericFields) {
|
|
102
|
+
if (typeof raw[key] === 'number' && isFinite(raw[key]) && raw[key] >= min && raw[key] <= max) {
|
|
103
|
+
defaults[key] = raw[key];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
73
107
|
}
|
|
74
108
|
const args = process.argv.slice(2);
|
|
75
109
|
const cliFiles = [];
|
|
@@ -108,14 +142,41 @@ function discoverServices(config) {
|
|
|
108
142
|
}
|
|
109
143
|
// --- Status Polling ---
|
|
110
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();
|
|
111
147
|
for (const group of state.groups) {
|
|
112
148
|
if (group.error)
|
|
113
149
|
continue;
|
|
114
|
-
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));
|
|
115
161
|
for (const [svc, st] of statuses) {
|
|
116
|
-
|
|
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
|
+
}
|
|
117
168
|
}
|
|
118
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;
|
|
119
180
|
}
|
|
120
181
|
// --- Log Pattern Scanning ---
|
|
121
182
|
function pollLogCounts(state) {
|
|
@@ -148,14 +209,18 @@ function pollLogCounts(state) {
|
|
|
148
209
|
child.stderr.on('data', (d) => { output += d.toString(); });
|
|
149
210
|
child.on('close', () => {
|
|
150
211
|
const counts = new Map();
|
|
151
|
-
for (const
|
|
212
|
+
for (const entry of scanPatterns) {
|
|
213
|
+
const group = Array.isArray(entry) ? entry : [entry];
|
|
214
|
+
const key = group[0];
|
|
152
215
|
let count = 0;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
+
}
|
|
157
222
|
}
|
|
158
|
-
counts.set(
|
|
223
|
+
counts.set(key, count);
|
|
159
224
|
}
|
|
160
225
|
state.logCounts.set(sk, counts);
|
|
161
226
|
remaining--;
|
|
@@ -244,20 +309,26 @@ function pollContainerStats(state) {
|
|
|
244
309
|
}
|
|
245
310
|
// --- Rendering ---
|
|
246
311
|
function render(state) {
|
|
247
|
-
let
|
|
312
|
+
let view = '';
|
|
248
313
|
if (state.mode === state_1.MODE.LIST) {
|
|
249
|
-
|
|
314
|
+
view = (0, renderer_1.renderListView)(state);
|
|
250
315
|
}
|
|
251
316
|
else if (state.mode === state_1.MODE.LOGS) {
|
|
252
|
-
|
|
317
|
+
view = (0, renderer_1.renderLogView)(state);
|
|
253
318
|
}
|
|
254
319
|
else if (state.mode === state_1.MODE.EXEC) {
|
|
255
|
-
|
|
320
|
+
view = (0, renderer_1.renderExecView)(state);
|
|
256
321
|
}
|
|
257
|
-
|
|
322
|
+
// View functions already embed CLEAR_EOL per line; just clear below last line
|
|
323
|
+
process.stdout.write((0, renderer_1.clearScreen)() + view + renderer_1.CLEAR_EOL + renderer_1.CLEAR_EOS);
|
|
258
324
|
}
|
|
259
325
|
function stripAnsi(str) {
|
|
260
|
-
return str.replace(
|
|
326
|
+
return str.replace(
|
|
327
|
+
// CSI sequences: \x1b[ ... letter
|
|
328
|
+
// OSC sequences: \x1b] ... BEL or \x1b] ... ST
|
|
329
|
+
// DCS/APC/PM/SOS sequences: \x1bP/\x1b_/\x1b^/\x1bX ... ST (where ST = \x1b\\)
|
|
330
|
+
// Two-byte escape sequences: \x1b + any char
|
|
331
|
+
/\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[P_^X][^\x1b]*(?:\x1b\\|\x07)|\x1b[^[\]P_^X]/g, '');
|
|
261
332
|
}
|
|
262
333
|
function throttledRender(state) {
|
|
263
334
|
const now = Date.now();
|
|
@@ -284,6 +355,7 @@ function updateSelectedLogs(state) {
|
|
|
284
355
|
return;
|
|
285
356
|
state.bottomSearchQuery = '';
|
|
286
357
|
state.bottomSearchActive = false;
|
|
358
|
+
clearBottomSearch(state);
|
|
287
359
|
if (moduleState.logFetchTimer) {
|
|
288
360
|
clearTimeout(moduleState.logFetchTimer);
|
|
289
361
|
moduleState.logFetchTimer = null;
|
|
@@ -304,9 +376,10 @@ function updateSelectedLogs(state) {
|
|
|
304
376
|
if (state.bottomLogLines.has(sk))
|
|
305
377
|
return;
|
|
306
378
|
state.bottomLogLines.set(sk, { action: 'logs', service: entry.service, lines: [] });
|
|
379
|
+
const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
|
|
307
380
|
moduleState.logFetchTimer = setTimeout(() => {
|
|
308
381
|
moduleState.logFetchTimer = null;
|
|
309
|
-
startBottomLogTail(state, sk,
|
|
382
|
+
startBottomLogTail(state, sk, effectiveFile, entry.service);
|
|
310
383
|
}, 500);
|
|
311
384
|
}
|
|
312
385
|
function startBottomLogTail(state, sk, file, service) {
|
|
@@ -347,11 +420,12 @@ function doRebuild(state) {
|
|
|
347
420
|
const sk = (0, state_1.statusKey)(entry.file, entry.service);
|
|
348
421
|
if (state.rebuilding.has(sk))
|
|
349
422
|
return;
|
|
423
|
+
const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
|
|
350
424
|
if (state.bottomLogTails.has(sk)) {
|
|
351
425
|
state.bottomLogTails.get(sk).kill('SIGTERM');
|
|
352
426
|
state.bottomLogTails.delete(sk);
|
|
353
427
|
}
|
|
354
|
-
const child = (0, docker_1.rebuildService)(
|
|
428
|
+
const child = (0, docker_1.rebuildService)(effectiveFile, entry.service, { noCache: state.noCache, noDeps: state.noDeps });
|
|
355
429
|
state.rebuilding.set(sk, child);
|
|
356
430
|
state.bottomLogLines.set(sk, { action: 'rebuilding', service: entry.service, lines: [] });
|
|
357
431
|
let lineBuf = '';
|
|
@@ -366,26 +440,36 @@ function doRebuild(state) {
|
|
|
366
440
|
if (newLines.length === 0)
|
|
367
441
|
return;
|
|
368
442
|
info.lines.push(...newLines);
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
443
|
+
if (state.mode === state_1.MODE.LOGS && state.logBuildKey === sk) {
|
|
444
|
+
state.logLines.push(...newLines);
|
|
445
|
+
if (state.logAutoScroll)
|
|
446
|
+
throttledRender(state);
|
|
447
|
+
}
|
|
372
448
|
if (state.mode === state_1.MODE.LIST)
|
|
373
449
|
throttledRender(state);
|
|
374
450
|
};
|
|
375
451
|
child.stdout.on('data', onData);
|
|
376
452
|
child.stderr.on('data', onData);
|
|
377
453
|
render(state);
|
|
378
|
-
child.on('close', () => {
|
|
454
|
+
child.on('close', (code) => {
|
|
379
455
|
state.rebuilding.delete(sk);
|
|
380
456
|
state.containerStatsHistory.delete(sk);
|
|
381
457
|
state.containerStats.delete(sk);
|
|
382
458
|
pollStatuses(state);
|
|
383
459
|
const info = state.bottomLogLines.get(sk);
|
|
460
|
+
if (code !== 0 && code !== null) {
|
|
461
|
+
if (info)
|
|
462
|
+
info.action = 'build_failed';
|
|
463
|
+
if (state.mode === state_1.MODE.LIST)
|
|
464
|
+
render(state);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
384
467
|
if (info) {
|
|
385
468
|
info.action = 'started';
|
|
386
|
-
|
|
469
|
+
if (state.logBuildKey !== sk)
|
|
470
|
+
info.lines = [];
|
|
387
471
|
}
|
|
388
|
-
startBottomLogTail(state, sk,
|
|
472
|
+
startBottomLogTail(state, sk, effectiveFile, entry.service);
|
|
389
473
|
if (state.mode === state_1.MODE.LIST)
|
|
390
474
|
render(state);
|
|
391
475
|
});
|
|
@@ -397,25 +481,33 @@ function doRestart(state) {
|
|
|
397
481
|
const sk = (0, state_1.statusKey)(entry.file, entry.service);
|
|
398
482
|
if (state.restarting.has(sk) || state.rebuilding.has(sk))
|
|
399
483
|
return;
|
|
484
|
+
const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
|
|
400
485
|
if (state.bottomLogTails.has(sk)) {
|
|
401
486
|
state.bottomLogTails.get(sk).kill('SIGTERM');
|
|
402
487
|
state.bottomLogTails.delete(sk);
|
|
403
488
|
}
|
|
404
|
-
const child = (0, docker_1.restartService)(
|
|
489
|
+
const child = (0, docker_1.restartService)(effectiveFile, entry.service);
|
|
405
490
|
state.restarting.set(sk, child);
|
|
406
491
|
state.bottomLogLines.set(sk, { action: 'restarting', service: entry.service, lines: [] });
|
|
407
492
|
render(state);
|
|
408
|
-
child.on('close', () => {
|
|
493
|
+
child.on('close', (code) => {
|
|
409
494
|
state.restarting.delete(sk);
|
|
410
495
|
state.containerStatsHistory.delete(sk);
|
|
411
496
|
state.containerStats.delete(sk);
|
|
412
497
|
pollStatuses(state);
|
|
413
498
|
const info = state.bottomLogLines.get(sk);
|
|
499
|
+
if (code !== 0 && code !== null) {
|
|
500
|
+
if (info)
|
|
501
|
+
info.action = 'restart_failed';
|
|
502
|
+
if (state.mode === state_1.MODE.LIST)
|
|
503
|
+
render(state);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
414
506
|
if (info) {
|
|
415
507
|
info.action = 'started';
|
|
416
508
|
info.lines = [];
|
|
417
509
|
}
|
|
418
|
-
startBottomLogTail(state, sk,
|
|
510
|
+
startBottomLogTail(state, sk, effectiveFile, entry.service);
|
|
419
511
|
if (state.mode === state_1.MODE.LIST)
|
|
420
512
|
render(state);
|
|
421
513
|
});
|
|
@@ -434,13 +526,21 @@ function doStop(state) {
|
|
|
434
526
|
state.bottomLogTails.get(sk).kill('SIGTERM');
|
|
435
527
|
state.bottomLogTails.delete(sk);
|
|
436
528
|
}
|
|
437
|
-
const
|
|
529
|
+
const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
|
|
530
|
+
const child = (0, docker_1.stopService)(effectiveFile, entry.service);
|
|
438
531
|
state.stopping.set(sk, child);
|
|
439
532
|
state.bottomLogLines.set(sk, { action: 'stopping', service: entry.service, lines: [] });
|
|
440
533
|
render(state);
|
|
441
|
-
child.on('close', () => {
|
|
534
|
+
child.on('close', (code) => {
|
|
442
535
|
state.stopping.delete(sk);
|
|
443
|
-
|
|
536
|
+
if (code !== 0 && code !== null) {
|
|
537
|
+
const info = state.bottomLogLines.get(sk);
|
|
538
|
+
if (info)
|
|
539
|
+
info.action = 'stop_failed';
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
state.bottomLogLines.delete(sk);
|
|
543
|
+
}
|
|
444
544
|
pollStatuses(state);
|
|
445
545
|
if (state.mode === state_1.MODE.LIST)
|
|
446
546
|
render(state);
|
|
@@ -456,23 +556,184 @@ function doStart(state) {
|
|
|
456
556
|
const st = state.statuses.get(sk);
|
|
457
557
|
if (st && st.state === 'running')
|
|
458
558
|
return;
|
|
459
|
-
const
|
|
559
|
+
const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
|
|
560
|
+
const child = (0, docker_1.startService)(effectiveFile, entry.service);
|
|
460
561
|
state.starting.set(sk, child);
|
|
461
562
|
state.bottomLogLines.set(sk, { action: 'starting', service: entry.service, lines: [] });
|
|
462
563
|
render(state);
|
|
463
|
-
child.on('close', () => {
|
|
564
|
+
child.on('close', (code) => {
|
|
464
565
|
state.starting.delete(sk);
|
|
465
566
|
pollStatuses(state);
|
|
466
567
|
const info = state.bottomLogLines.get(sk);
|
|
568
|
+
if (code !== 0 && code !== null) {
|
|
569
|
+
if (info)
|
|
570
|
+
info.action = 'start_failed';
|
|
571
|
+
if (state.mode === state_1.MODE.LIST)
|
|
572
|
+
render(state);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
467
575
|
if (info) {
|
|
468
576
|
info.action = 'started';
|
|
469
577
|
info.lines = [];
|
|
470
578
|
}
|
|
471
|
-
startBottomLogTail(state, sk,
|
|
579
|
+
startBottomLogTail(state, sk, effectiveFile, entry.service);
|
|
472
580
|
if (state.mode === state_1.MODE.LIST)
|
|
473
581
|
render(state);
|
|
474
582
|
});
|
|
475
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
|
+
}
|
|
476
737
|
// --- Watch ---
|
|
477
738
|
function doWatch(state) {
|
|
478
739
|
const entry = (0, state_1.selectedEntry)(state);
|
|
@@ -500,7 +761,8 @@ function doWatch(state) {
|
|
|
500
761
|
render(state);
|
|
501
762
|
return;
|
|
502
763
|
}
|
|
503
|
-
const
|
|
764
|
+
const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
|
|
765
|
+
const child = (0, docker_1.watchService)(effectiveFile, entry.service);
|
|
504
766
|
state.watching.set(sk, child);
|
|
505
767
|
state.bottomLogLines.set(sk, { action: 'watching', service: entry.service, lines: [] });
|
|
506
768
|
state.showBottomLogs = true;
|
|
@@ -598,9 +860,21 @@ function doCascadeRebuild(state) {
|
|
|
598
860
|
const sk = (0, state_1.statusKey)(entry.file, entry.service);
|
|
599
861
|
if (state.rebuilding.has(sk) || state.cascading.has(sk))
|
|
600
862
|
return;
|
|
601
|
-
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
|
+
}
|
|
602
877
|
if (!graph) {
|
|
603
|
-
// No graph available, fall back to regular rebuild
|
|
604
878
|
doRebuild(state);
|
|
605
879
|
return;
|
|
606
880
|
}
|
|
@@ -619,7 +893,7 @@ function doCascadeRebuild(state) {
|
|
|
619
893
|
state.cascading.set(sk, cascade);
|
|
620
894
|
state.bottomLogLines.set(sk, { action: 'cascading', service: entry.service, lines: [] });
|
|
621
895
|
state.showBottomLogs = true;
|
|
622
|
-
executeCascadeStep(state,
|
|
896
|
+
executeCascadeStep(state, effectiveFile, sk, cascade);
|
|
623
897
|
render(state);
|
|
624
898
|
}
|
|
625
899
|
function executeCascadeStep(state, file, sk, cascade) {
|
|
@@ -633,10 +907,9 @@ function executeCascadeStep(state, file, sk, cascade) {
|
|
|
633
907
|
return;
|
|
634
908
|
}
|
|
635
909
|
step.status = 'in_progress';
|
|
636
|
-
const maxLines = state.config.bottomLogCount || 10;
|
|
637
910
|
let child;
|
|
638
911
|
if (step.action === 'rebuild') {
|
|
639
|
-
child = (0, docker_1.rebuildService)(file, step.service, { noCache: state.noCache });
|
|
912
|
+
child = (0, docker_1.rebuildService)(file, step.service, { noCache: state.noCache, noDeps: state.noDeps });
|
|
640
913
|
}
|
|
641
914
|
else {
|
|
642
915
|
child = (0, docker_1.restartService)(file, step.service);
|
|
@@ -654,8 +927,11 @@ function executeCascadeStep(state, file, sk, cascade) {
|
|
|
654
927
|
if (newLines.length === 0)
|
|
655
928
|
return;
|
|
656
929
|
info.lines.push(...newLines);
|
|
657
|
-
if (
|
|
658
|
-
|
|
930
|
+
if (state.mode === state_1.MODE.LOGS && state.logBuildKey === sk) {
|
|
931
|
+
state.logLines.push(...newLines);
|
|
932
|
+
if (state.logAutoScroll)
|
|
933
|
+
throttledRender(state);
|
|
934
|
+
}
|
|
659
935
|
if (state.mode === state_1.MODE.LIST)
|
|
660
936
|
throttledRender(state);
|
|
661
937
|
};
|
|
@@ -753,13 +1029,18 @@ function isCdCommand(cmd) {
|
|
|
753
1029
|
return null;
|
|
754
1030
|
return match[2] ? match[2].trim() : '';
|
|
755
1031
|
}
|
|
1032
|
+
function shellEscape(str) {
|
|
1033
|
+
return "'" + str.replace(/'/g, "'\\''") + "'";
|
|
1034
|
+
}
|
|
756
1035
|
function runExecCommand(state) {
|
|
757
1036
|
const cmd = state.execInput.trim();
|
|
758
1037
|
if (!cmd || !state.execContainerId)
|
|
759
1038
|
return;
|
|
760
|
-
// Add to history
|
|
1039
|
+
// Add to history (capped at 1000 entries)
|
|
761
1040
|
if (state.execHistory.length === 0 || state.execHistory[state.execHistory.length - 1] !== cmd) {
|
|
762
1041
|
state.execHistory.push(cmd);
|
|
1042
|
+
if (state.execHistory.length > 1000)
|
|
1043
|
+
state.execHistory.shift();
|
|
763
1044
|
}
|
|
764
1045
|
state.execHistoryIdx = -1;
|
|
765
1046
|
state.execInput = '';
|
|
@@ -772,7 +1053,7 @@ function runExecCommand(state) {
|
|
|
772
1053
|
// Handle cd commands — resolve new working directory
|
|
773
1054
|
const cdTarget = isCdCommand(cmd);
|
|
774
1055
|
if (cdTarget !== null) {
|
|
775
|
-
const resolveCmd = cdTarget ? `cd ${cdTarget} && pwd` : 'cd && pwd';
|
|
1056
|
+
const resolveCmd = cdTarget ? `cd ${shellEscape(cdTarget)} && pwd` : 'cd && pwd';
|
|
776
1057
|
const child = (0, docker_1.execInContainer)(state.execContainerId, resolveCmd, state.execCwd || undefined);
|
|
777
1058
|
state.execChild = child;
|
|
778
1059
|
let stdout = '';
|
|
@@ -785,8 +1066,9 @@ function runExecCommand(state) {
|
|
|
785
1066
|
if (code === 0) {
|
|
786
1067
|
const lines = stdout.trim().split('\n');
|
|
787
1068
|
const newCwd = lines[lines.length - 1].trim();
|
|
788
|
-
if (newCwd)
|
|
1069
|
+
if (newCwd && newCwd.startsWith('/') && newCwd.length < 4096 && !/[\x00-\x1f]/.test(newCwd)) {
|
|
789
1070
|
state.execCwd = newCwd;
|
|
1071
|
+
}
|
|
790
1072
|
}
|
|
791
1073
|
else {
|
|
792
1074
|
const errLines = stderr.trim().split('\n').filter(Boolean);
|
|
@@ -841,44 +1123,66 @@ function enterLogs(state) {
|
|
|
841
1123
|
clearTimeout(moduleState.logFetchTimer);
|
|
842
1124
|
moduleState.logFetchTimer = null;
|
|
843
1125
|
}
|
|
1126
|
+
// Carry over bottom panel search query to full log search
|
|
1127
|
+
const carryQuery = state.bottomSearchQuery || '';
|
|
1128
|
+
clearBottomSearch(state);
|
|
1129
|
+
const sk = (0, state_1.statusKey)(entry.file, entry.service);
|
|
1130
|
+
const info = state.bottomLogLines.get(sk);
|
|
1131
|
+
const isBuilding = state.rebuilding.has(sk) || state.cascading.has(sk);
|
|
1132
|
+
const isBuildFailed = info && info.action === 'build_failed';
|
|
844
1133
|
state.mode = state_1.MODE.LOGS;
|
|
845
1134
|
state.logLines = [];
|
|
846
1135
|
state.logScrollOffset = 0;
|
|
847
1136
|
state.logAutoScroll = true;
|
|
848
|
-
state.logSearchQuery =
|
|
1137
|
+
state.logSearchQuery = carryQuery;
|
|
849
1138
|
state.logSearchActive = false;
|
|
850
1139
|
state.logSearchMatches = [];
|
|
851
1140
|
state.logSearchMatchIdx = -1;
|
|
852
|
-
|
|
853
|
-
state.
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
state.logLines.push(stripAnsi(line));
|
|
1141
|
+
state.logFetchedTailCount = 200;
|
|
1142
|
+
state.logHistoryLoaded = false;
|
|
1143
|
+
state.logHistoryLoading = false;
|
|
1144
|
+
state.logSearchPending = !!carryQuery;
|
|
1145
|
+
state.logHistoryChild = null;
|
|
1146
|
+
if (isBuilding || isBuildFailed) {
|
|
1147
|
+
// Show build output instead of runtime logs
|
|
1148
|
+
state.logBuildKey = sk;
|
|
1149
|
+
if (info) {
|
|
1150
|
+
state.logLines = [...info.lines];
|
|
863
1151
|
}
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
1152
|
+
state.logHistoryLoaded = true;
|
|
1153
|
+
}
|
|
1154
|
+
else {
|
|
1155
|
+
state.logBuildKey = null;
|
|
1156
|
+
const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
|
|
1157
|
+
const child = (0, docker_1.tailLogs)(effectiveFile, entry.service, 200);
|
|
1158
|
+
state.logChild = child;
|
|
1159
|
+
let lineBuf = '';
|
|
1160
|
+
const onData = (data) => {
|
|
1161
|
+
lineBuf += data.toString();
|
|
1162
|
+
const parts = lineBuf.split(/\r?\n|\r/);
|
|
1163
|
+
lineBuf = parts.pop();
|
|
1164
|
+
if (parts.length === 0)
|
|
1165
|
+
return;
|
|
1166
|
+
for (const line of parts) {
|
|
1167
|
+
state.logLines.push(stripAnsi(line));
|
|
869
1168
|
}
|
|
1169
|
+
if (state.logAutoScroll) {
|
|
1170
|
+
throttledRender(state);
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
child.stdout.on('data', onData);
|
|
1174
|
+
child.stderr.on('data', onData);
|
|
1175
|
+
child.on('close', () => {
|
|
1176
|
+
if (state.logChild === child) {
|
|
1177
|
+
state.logChild = null;
|
|
1178
|
+
}
|
|
1179
|
+
});
|
|
1180
|
+
// If carrying a search query from bottom panel, load full history to search
|
|
1181
|
+
if (carryQuery) {
|
|
1182
|
+
state.logFetchedTailCount = 5000;
|
|
1183
|
+
loadMoreLogHistory(state);
|
|
870
1184
|
}
|
|
871
|
-
|
|
872
|
-
throttledRender(state);
|
|
873
|
-
}
|
|
874
|
-
};
|
|
875
|
-
child.stdout.on('data', onData);
|
|
876
|
-
child.stderr.on('data', onData);
|
|
877
|
-
child.on('close', () => {
|
|
878
|
-
if (state.logChild === child) {
|
|
879
|
-
state.logChild = null;
|
|
880
|
-
}
|
|
881
|
-
});
|
|
1185
|
+
}
|
|
882
1186
|
render(state);
|
|
883
1187
|
}
|
|
884
1188
|
function exitLogs(state) {
|
|
@@ -886,11 +1190,73 @@ function exitLogs(state) {
|
|
|
886
1190
|
state.logChild.kill('SIGTERM');
|
|
887
1191
|
state.logChild = null;
|
|
888
1192
|
}
|
|
1193
|
+
if (state.logHistoryChild) {
|
|
1194
|
+
state.logHistoryChild.kill('SIGTERM');
|
|
1195
|
+
state.logHistoryChild = null;
|
|
1196
|
+
}
|
|
889
1197
|
state.logLines = [];
|
|
1198
|
+
state.logBuildKey = null;
|
|
1199
|
+
state.logHistoryLoaded = false;
|
|
1200
|
+
state.logHistoryLoading = false;
|
|
1201
|
+
state.logSearchPending = false;
|
|
890
1202
|
state.mode = state_1.MODE.LIST;
|
|
891
1203
|
pollStatuses(state);
|
|
892
1204
|
render(state);
|
|
893
1205
|
}
|
|
1206
|
+
// --- Log History Loading ---
|
|
1207
|
+
function loadMoreLogHistory(state) {
|
|
1208
|
+
if (state.logHistoryLoaded || state.logHistoryLoading)
|
|
1209
|
+
return;
|
|
1210
|
+
const entry = (0, state_1.selectedEntry)(state);
|
|
1211
|
+
if (!entry)
|
|
1212
|
+
return;
|
|
1213
|
+
// Escalate: 200 → 1000 → 5000 → all
|
|
1214
|
+
let nextTail;
|
|
1215
|
+
if (state.logFetchedTailCount < 1000)
|
|
1216
|
+
nextTail = 1000;
|
|
1217
|
+
else if (state.logFetchedTailCount < 5000)
|
|
1218
|
+
nextTail = 5000;
|
|
1219
|
+
else
|
|
1220
|
+
nextTail = 'all';
|
|
1221
|
+
state.logHistoryLoading = true;
|
|
1222
|
+
const snapshotLen = state.logLines.length;
|
|
1223
|
+
const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
|
|
1224
|
+
const child = (0, docker_1.fetchServiceLogs)(effectiveFile, entry.service, nextTail);
|
|
1225
|
+
state.logHistoryChild = child;
|
|
1226
|
+
let output = '';
|
|
1227
|
+
child.stdout.on('data', (d) => { output += d.toString(); });
|
|
1228
|
+
child.stderr.on('data', (d) => { output += d.toString(); });
|
|
1229
|
+
child.on('close', () => {
|
|
1230
|
+
if (state.logHistoryChild === child) {
|
|
1231
|
+
state.logHistoryChild = null;
|
|
1232
|
+
}
|
|
1233
|
+
state.logHistoryLoading = false;
|
|
1234
|
+
const fetchedLines = output.split(/\r?\n|\r/).filter(l => l.length > 0).map(stripAnsi).filter(Boolean);
|
|
1235
|
+
if (fetchedLines.length <= snapshotLen) {
|
|
1236
|
+
// No more history available
|
|
1237
|
+
state.logHistoryLoaded = true;
|
|
1238
|
+
}
|
|
1239
|
+
else {
|
|
1240
|
+
// Merge: fetched history + any new lines that arrived during the fetch
|
|
1241
|
+
const newLiveLines = state.logLines.slice(snapshotLen);
|
|
1242
|
+
const oldOffset = state.logScrollOffset;
|
|
1243
|
+
const added = fetchedLines.length - snapshotLen;
|
|
1244
|
+
state.logLines = [...fetchedLines, ...newLiveLines];
|
|
1245
|
+
// Adjust scroll offset to maintain visual position
|
|
1246
|
+
if (!state.logAutoScroll) {
|
|
1247
|
+
state.logScrollOffset = oldOffset + added;
|
|
1248
|
+
}
|
|
1249
|
+
state.logFetchedTailCount = nextTail === 'all' ? Infinity : nextTail;
|
|
1250
|
+
if (nextTail === 'all')
|
|
1251
|
+
state.logHistoryLoaded = true;
|
|
1252
|
+
}
|
|
1253
|
+
if (state.logSearchPending) {
|
|
1254
|
+
state.logSearchPending = false;
|
|
1255
|
+
executeLogSearch(state);
|
|
1256
|
+
}
|
|
1257
|
+
render(state);
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
894
1260
|
// --- Log Search ---
|
|
895
1261
|
function executeLogSearch(state) {
|
|
896
1262
|
const query = state.logSearchQuery;
|
|
@@ -914,7 +1280,8 @@ function scrollToLogLine(state, lineIdx) {
|
|
|
914
1280
|
const headerHeight = 9;
|
|
915
1281
|
const availableRows = Math.max(1, rows - headerHeight);
|
|
916
1282
|
const totalLines = state.logLines.length;
|
|
917
|
-
|
|
1283
|
+
const maxOffset = Math.max(0, totalLines - availableRows);
|
|
1284
|
+
state.logScrollOffset = Math.min(maxOffset, Math.max(0, totalLines - lineIdx - Math.floor(availableRows / 2)));
|
|
918
1285
|
state.logAutoScroll = state.logScrollOffset === 0;
|
|
919
1286
|
render(state);
|
|
920
1287
|
}
|
|
@@ -930,6 +1297,79 @@ function jumpToPrevMatch(state) {
|
|
|
930
1297
|
state.logSearchMatchIdx = (state.logSearchMatchIdx - 1 + state.logSearchMatches.length) % state.logSearchMatches.length;
|
|
931
1298
|
scrollToLogLine(state, state.logSearchMatches[state.logSearchMatchIdx]);
|
|
932
1299
|
}
|
|
1300
|
+
// --- Bottom Panel Search ---
|
|
1301
|
+
function executeBottomSearch(state) {
|
|
1302
|
+
const entry = (0, state_1.selectedEntry)(state);
|
|
1303
|
+
if (!entry || !state.bottomSearchQuery)
|
|
1304
|
+
return;
|
|
1305
|
+
const sk = (0, state_1.statusKey)(entry.file, entry.service);
|
|
1306
|
+
const info = state.bottomLogLines.get(sk);
|
|
1307
|
+
if (!info)
|
|
1308
|
+
return;
|
|
1309
|
+
// Save current tail lines so we can restore them later
|
|
1310
|
+
if (!state.bottomSearchSavedLines.has(sk)) {
|
|
1311
|
+
state.bottomSearchSavedLines.set(sk, [...info.lines]);
|
|
1312
|
+
}
|
|
1313
|
+
// Kill any previous search fetch
|
|
1314
|
+
if (state.bottomSearchChild) {
|
|
1315
|
+
state.bottomSearchChild.kill('SIGTERM');
|
|
1316
|
+
state.bottomSearchChild = null;
|
|
1317
|
+
}
|
|
1318
|
+
state.bottomSearchLoading = true;
|
|
1319
|
+
state.bottomSearchTotalMatches = 0;
|
|
1320
|
+
render(state);
|
|
1321
|
+
const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
|
|
1322
|
+
const child = (0, docker_1.fetchServiceLogs)(effectiveFile, entry.service, 'all');
|
|
1323
|
+
state.bottomSearchChild = child;
|
|
1324
|
+
let output = '';
|
|
1325
|
+
child.stdout.on('data', (d) => { output += d.toString(); });
|
|
1326
|
+
child.stderr.on('data', (d) => { output += d.toString(); });
|
|
1327
|
+
child.on('close', () => {
|
|
1328
|
+
if (state.bottomSearchChild !== child)
|
|
1329
|
+
return; // superseded
|
|
1330
|
+
state.bottomSearchChild = null;
|
|
1331
|
+
state.bottomSearchLoading = false;
|
|
1332
|
+
const query = state.bottomSearchQuery;
|
|
1333
|
+
if (!query) {
|
|
1334
|
+
clearBottomSearch(state);
|
|
1335
|
+
render(state);
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
const lowerQuery = query.toLowerCase();
|
|
1339
|
+
const allLines = output.split(/\r?\n|\r/).filter(l => l.trim().length > 0).map(stripAnsi).filter(Boolean);
|
|
1340
|
+
const matchingLines = [];
|
|
1341
|
+
for (const line of allLines) {
|
|
1342
|
+
if (line.toLowerCase().includes(lowerQuery)) {
|
|
1343
|
+
matchingLines.push(line);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
state.bottomSearchTotalMatches = matchingLines.length;
|
|
1347
|
+
// Show the last N matching lines in the bottom panel
|
|
1348
|
+
const maxLines = state.config.bottomLogCount || 10;
|
|
1349
|
+
const currentInfo = state.bottomLogLines.get(sk);
|
|
1350
|
+
if (currentInfo) {
|
|
1351
|
+
currentInfo.lines = matchingLines.slice(-maxLines);
|
|
1352
|
+
}
|
|
1353
|
+
if (state.mode === state_1.MODE.LIST)
|
|
1354
|
+
render(state);
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
function clearBottomSearch(state) {
|
|
1358
|
+
if (state.bottomSearchChild) {
|
|
1359
|
+
state.bottomSearchChild.kill('SIGTERM');
|
|
1360
|
+
state.bottomSearchChild = null;
|
|
1361
|
+
}
|
|
1362
|
+
state.bottomSearchLoading = false;
|
|
1363
|
+
state.bottomSearchTotalMatches = 0;
|
|
1364
|
+
// Restore saved tail lines
|
|
1365
|
+
if (state.selectedLogKey && state.bottomSearchSavedLines.has(state.selectedLogKey)) {
|
|
1366
|
+
const info = state.bottomLogLines.get(state.selectedLogKey);
|
|
1367
|
+
if (info) {
|
|
1368
|
+
info.lines = state.bottomSearchSavedLines.get(state.selectedLogKey);
|
|
1369
|
+
}
|
|
1370
|
+
state.bottomSearchSavedLines.delete(state.selectedLogKey);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
933
1373
|
// --- Input Handling ---
|
|
934
1374
|
function handleKeypress(state, key) {
|
|
935
1375
|
if (key === '\x03' && state.mode !== state_1.MODE.EXEC && !state.execActive) {
|
|
@@ -1002,8 +1442,22 @@ function handleKeypress(state, key) {
|
|
|
1002
1442
|
}
|
|
1003
1443
|
else if (key === '\r') {
|
|
1004
1444
|
state.logSearchActive = false;
|
|
1005
|
-
|
|
1006
|
-
|
|
1445
|
+
if (!state.logHistoryLoaded && !state.logHistoryLoading) {
|
|
1446
|
+
// Load all history, then search
|
|
1447
|
+
state.logSearchPending = true;
|
|
1448
|
+
state.logFetchedTailCount = 5000; // jump straight to 'all'
|
|
1449
|
+
loadMoreLogHistory(state);
|
|
1450
|
+
render(state);
|
|
1451
|
+
}
|
|
1452
|
+
else if (state.logHistoryLoading) {
|
|
1453
|
+
// Already loading — search will run when it finishes
|
|
1454
|
+
state.logSearchPending = true;
|
|
1455
|
+
render(state);
|
|
1456
|
+
}
|
|
1457
|
+
else {
|
|
1458
|
+
executeLogSearch(state);
|
|
1459
|
+
render(state);
|
|
1460
|
+
}
|
|
1007
1461
|
}
|
|
1008
1462
|
else if (key === '\x7f' || key === '\b') {
|
|
1009
1463
|
state.logSearchQuery = state.logSearchQuery.slice(0, -1);
|
|
@@ -1017,12 +1471,31 @@ function handleKeypress(state, key) {
|
|
|
1017
1471
|
}
|
|
1018
1472
|
const rows = process.stdout.rows ?? 24;
|
|
1019
1473
|
const pageSize = Math.max(1, Math.floor(rows / 2));
|
|
1020
|
-
const
|
|
1474
|
+
const availableRows = Math.max(1, rows - 9);
|
|
1475
|
+
const maxOffset = Math.max(0, state.logLines.length - availableRows);
|
|
1476
|
+
// Trigger lazy history load when scrolled near the top
|
|
1477
|
+
const checkLoadMore = () => {
|
|
1478
|
+
const availableRows = Math.max(1, rows - 9);
|
|
1479
|
+
const linesFromTop = state.logLines.length - state.logScrollOffset - availableRows;
|
|
1480
|
+
if (linesFromTop < availableRows) {
|
|
1481
|
+
loadMoreLogHistory(state);
|
|
1482
|
+
}
|
|
1483
|
+
};
|
|
1021
1484
|
switch (key) {
|
|
1022
1485
|
case 'f':
|
|
1023
|
-
case '\x1b':
|
|
1024
1486
|
exitLogs(state);
|
|
1025
1487
|
break;
|
|
1488
|
+
case '\x1b':
|
|
1489
|
+
if (state.logSearchQuery) {
|
|
1490
|
+
state.logSearchQuery = '';
|
|
1491
|
+
state.logSearchMatches = [];
|
|
1492
|
+
state.logSearchMatchIdx = -1;
|
|
1493
|
+
render(state);
|
|
1494
|
+
}
|
|
1495
|
+
else {
|
|
1496
|
+
exitLogs(state);
|
|
1497
|
+
}
|
|
1498
|
+
break;
|
|
1026
1499
|
case 'q':
|
|
1027
1500
|
cleanup(state);
|
|
1028
1501
|
process.exit(0);
|
|
@@ -1031,6 +1504,7 @@ function handleKeypress(state, key) {
|
|
|
1031
1504
|
case '\x1b[A':
|
|
1032
1505
|
state.logAutoScroll = false;
|
|
1033
1506
|
state.logScrollOffset = Math.min(maxOffset, state.logScrollOffset + 1);
|
|
1507
|
+
checkLoadMore();
|
|
1034
1508
|
render(state);
|
|
1035
1509
|
break;
|
|
1036
1510
|
case 'j':
|
|
@@ -1050,6 +1524,7 @@ function handleKeypress(state, key) {
|
|
|
1050
1524
|
case '\x15': // Ctrl+U
|
|
1051
1525
|
state.logAutoScroll = false;
|
|
1052
1526
|
state.logScrollOffset = Math.min(maxOffset, state.logScrollOffset + pageSize);
|
|
1527
|
+
checkLoadMore();
|
|
1053
1528
|
render(state);
|
|
1054
1529
|
break;
|
|
1055
1530
|
case '\x04': // Ctrl+D
|
|
@@ -1072,6 +1547,34 @@ function handleKeypress(state, key) {
|
|
|
1072
1547
|
}
|
|
1073
1548
|
return;
|
|
1074
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
|
+
}
|
|
1075
1578
|
// LIST mode - inline exec input
|
|
1076
1579
|
if (state.execActive) {
|
|
1077
1580
|
if (key === '\x1b') {
|
|
@@ -1135,10 +1638,14 @@ function handleKeypress(state, key) {
|
|
|
1135
1638
|
if (key === '\x1b') {
|
|
1136
1639
|
state.bottomSearchActive = false;
|
|
1137
1640
|
state.bottomSearchQuery = '';
|
|
1641
|
+
clearBottomSearch(state);
|
|
1138
1642
|
render(state);
|
|
1139
1643
|
}
|
|
1140
1644
|
else if (key === '\r') {
|
|
1141
1645
|
state.bottomSearchActive = false;
|
|
1646
|
+
if (state.bottomSearchQuery) {
|
|
1647
|
+
executeBottomSearch(state);
|
|
1648
|
+
}
|
|
1142
1649
|
render(state);
|
|
1143
1650
|
}
|
|
1144
1651
|
else if (key === '\x7f' || key === '\b') {
|
|
@@ -1201,6 +1708,10 @@ function handleKeypress(state, key) {
|
|
|
1201
1708
|
state.noCache = !state.noCache;
|
|
1202
1709
|
render(state);
|
|
1203
1710
|
break;
|
|
1711
|
+
case 'o':
|
|
1712
|
+
state.noDeps = !state.noDeps;
|
|
1713
|
+
render(state);
|
|
1714
|
+
break;
|
|
1204
1715
|
case 'f':
|
|
1205
1716
|
case '\r':
|
|
1206
1717
|
enterLogs(state);
|
|
@@ -1209,6 +1720,9 @@ function handleKeypress(state, key) {
|
|
|
1209
1720
|
state.showBottomLogs = !state.showBottomLogs;
|
|
1210
1721
|
render(state);
|
|
1211
1722
|
break;
|
|
1723
|
+
case 't':
|
|
1724
|
+
openWorktreePicker(state);
|
|
1725
|
+
break;
|
|
1212
1726
|
case 'q':
|
|
1213
1727
|
cleanup(state);
|
|
1214
1728
|
process.exit(0);
|
|
@@ -1262,7 +1776,7 @@ function createInputHandler(state) {
|
|
|
1262
1776
|
}
|
|
1263
1777
|
const ch = buf[0];
|
|
1264
1778
|
buf = buf.slice(1);
|
|
1265
|
-
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) {
|
|
1266
1780
|
handleKeypress(state, ch);
|
|
1267
1781
|
continue;
|
|
1268
1782
|
}
|
|
@@ -1276,7 +1790,14 @@ function createInputHandler(state) {
|
|
|
1276
1790
|
}
|
|
1277
1791
|
else if (state.mode === state_1.MODE.LOGS) {
|
|
1278
1792
|
state.logAutoScroll = false;
|
|
1279
|
-
|
|
1793
|
+
const ggRows = process.stdout.rows ?? 24;
|
|
1794
|
+
const ggAvailable = Math.max(1, ggRows - 9);
|
|
1795
|
+
state.logScrollOffset = Math.max(0, state.logLines.length - ggAvailable);
|
|
1796
|
+
// Load all history so we can scroll to the very top
|
|
1797
|
+
if (!state.logHistoryLoaded) {
|
|
1798
|
+
state.logFetchedTailCount = 5000; // jump to 'all'
|
|
1799
|
+
loadMoreLogHistory(state);
|
|
1800
|
+
}
|
|
1280
1801
|
}
|
|
1281
1802
|
render(state);
|
|
1282
1803
|
continue;
|
|
@@ -1330,6 +1851,10 @@ function cleanup(state) {
|
|
|
1330
1851
|
state.execChild = null;
|
|
1331
1852
|
}
|
|
1332
1853
|
state.execActive = false;
|
|
1854
|
+
if (state.bottomSearchChild) {
|
|
1855
|
+
state.bottomSearchChild.kill('SIGTERM');
|
|
1856
|
+
state.bottomSearchChild = null;
|
|
1857
|
+
}
|
|
1333
1858
|
for (const [, child] of state.bottomLogTails) {
|
|
1334
1859
|
child.kill('SIGTERM');
|
|
1335
1860
|
}
|
|
@@ -1351,7 +1876,7 @@ function cleanup(state) {
|
|
|
1351
1876
|
if (state.statsTimer) {
|
|
1352
1877
|
clearInterval(state.statsTimer);
|
|
1353
1878
|
}
|
|
1354
|
-
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');
|
|
1355
1880
|
}
|
|
1356
1881
|
// Expose for testing
|
|
1357
1882
|
function _getModuleState() {
|
|
@@ -1362,6 +1887,8 @@ function _setModuleState(ms) {
|
|
|
1362
1887
|
}
|
|
1363
1888
|
// --- Main ---
|
|
1364
1889
|
function main() {
|
|
1890
|
+
// Enter alternate screen buffer so pre-launch output (e.g. npx install) is hidden
|
|
1891
|
+
process.stdout.write('\x1b[?1049h');
|
|
1365
1892
|
const config = loadConfig();
|
|
1366
1893
|
const state = (0, state_1.createState)(config);
|
|
1367
1894
|
state.groups = discoverServices(config);
|