recomposable 1.1.7 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -8
- package/dist/components/App.d.ts +10 -0
- package/dist/components/App.js +149 -0
- package/dist/components/App.js.map +1 -0
- package/dist/components/BottomPanel.d.ts +9 -0
- package/dist/components/BottomPanel.js +205 -0
- package/dist/components/BottomPanel.js.map +1 -0
- package/dist/components/ExecScreen.d.ts +9 -0
- package/dist/components/ExecScreen.js +21 -0
- package/dist/components/ExecScreen.js.map +1 -0
- package/dist/components/Legend.d.ts +3 -0
- package/dist/components/Legend.js +86 -0
- package/dist/components/Legend.js.map +1 -0
- package/dist/components/ListScreen.d.ts +9 -0
- package/dist/components/ListScreen.js +85 -0
- package/dist/components/ListScreen.js.map +1 -0
- package/dist/components/LogScreen.d.ts +9 -0
- package/dist/components/LogScreen.js +130 -0
- package/dist/components/LogScreen.js.map +1 -0
- package/dist/components/Logo.d.ts +2 -0
- package/dist/components/Logo.js +8 -0
- package/dist/components/Logo.js.map +1 -0
- package/dist/components/Separator.d.ts +6 -0
- package/dist/components/Separator.js +8 -0
- package/dist/components/Separator.js.map +1 -0
- package/dist/components/ServiceRow.d.ts +11 -0
- package/dist/components/ServiceRow.js +159 -0
- package/dist/components/ServiceRow.js.map +1 -0
- package/dist/index.d.ts +26 -1
- package/dist/index.js +715 -293
- package/dist/index.js.map +1 -1
- package/dist/lib/docker.d.ts +12 -10
- package/dist/lib/docker.js +190 -103
- package/dist/lib/docker.js.map +1 -1
- package/dist/lib/inkTheme.d.ts +18 -0
- package/dist/lib/inkTheme.js +33 -0
- package/dist/lib/inkTheme.js.map +1 -0
- package/dist/lib/renderer.d.ts +1 -1
- package/dist/lib/renderer.js +118 -75
- package/dist/lib/renderer.js.map +1 -1
- package/dist/lib/state.d.ts +12 -1
- package/dist/lib/state.js +30 -21
- package/dist/lib/state.js.map +1 -1
- package/dist/lib/theme.js +13 -23
- package/dist/lib/theme.js.map +1 -1
- package/dist/lib/types.d.ts +14 -0
- package/dist/lib/types.js +1 -4
- package/dist/lib/types.js.map +1 -1
- package/package.json +8 -1
package/dist/index.js
CHANGED
|
@@ -1,55 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
};
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
exports.stripAnsi = stripAnsi;
|
|
16
|
-
exports.throttledRender = throttledRender;
|
|
17
|
-
exports.updateSelectedLogs = updateSelectedLogs;
|
|
18
|
-
exports.doRebuild = doRebuild;
|
|
19
|
-
exports.doRestart = doRestart;
|
|
20
|
-
exports.doStop = doStop;
|
|
21
|
-
exports.doStart = doStart;
|
|
22
|
-
exports.mapComposeFileToWorktree = mapComposeFileToWorktree;
|
|
23
|
-
exports.openWorktreePicker = openWorktreePicker;
|
|
24
|
-
exports.doWorktreeSwitch = doWorktreeSwitch;
|
|
25
|
-
exports.doWatch = doWatch;
|
|
26
|
-
exports.initDepGraphs = initDepGraphs;
|
|
27
|
-
exports.doCascadeRebuild = doCascadeRebuild;
|
|
28
|
-
exports.enterExecInline = enterExecInline;
|
|
29
|
-
exports.enterExec = enterExec;
|
|
30
|
-
exports.exitExec = exitExec;
|
|
31
|
-
exports.shellEscape = shellEscape;
|
|
32
|
-
exports.runExecCommand = runExecCommand;
|
|
33
|
-
exports.enterLogs = enterLogs;
|
|
34
|
-
exports.exitLogs = exitLogs;
|
|
35
|
-
exports.loadMoreLogHistory = loadMoreLogHistory;
|
|
36
|
-
exports.executeLogSearch = executeLogSearch;
|
|
37
|
-
exports.jumpToNextMatch = jumpToNextMatch;
|
|
38
|
-
exports.jumpToPrevMatch = jumpToPrevMatch;
|
|
39
|
-
exports.executeBottomSearch = executeBottomSearch;
|
|
40
|
-
exports.clearBottomSearch = clearBottomSearch;
|
|
41
|
-
exports.handleKeypress = handleKeypress;
|
|
42
|
-
exports.createInputHandler = createInputHandler;
|
|
43
|
-
exports.cleanup = cleanup;
|
|
44
|
-
exports._getModuleState = _getModuleState;
|
|
45
|
-
exports._setModuleState = _setModuleState;
|
|
46
|
-
const fs_1 = __importDefault(require("fs"));
|
|
47
|
-
const path_1 = __importDefault(require("path"));
|
|
48
|
-
const docker_1 = require("./lib/docker");
|
|
49
|
-
const state_1 = require("./lib/state");
|
|
50
|
-
const renderer_1 = require("./lib/renderer");
|
|
51
|
-
const theme_1 = require("./lib/theme");
|
|
52
|
-
function createModuleState() {
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { exec } from 'child_process';
|
|
6
|
+
import { listServices, getStatusesAsync, rebuildService, restartService, stopService, startService, tailLogs, fetchServiceLogs, getContainerIdAsync, tailContainerLogs, fetchContainerLogs, fetchContainerStats, parseStatsLine, isWatchAvailable, watchService, parseDependencyGraph, execInContainer, getGitRoot, listGitWorktrees, validateServiceInComposeFile } from './lib/docker.js';
|
|
7
|
+
import { MODE, createState, statusKey, buildFlatList, moveCursor, selectedEntry, getEffectiveFile, getComposeTarget, composeProjectName } from './lib/state.js';
|
|
8
|
+
import { clearScreen, showCursor, renderListView, renderLogView, renderExecView, CLEAR_EOL, CLEAR_EOS } from './lib/renderer.js';
|
|
9
|
+
import { detectTheme, getPalette, setActivePalette } from './lib/theme.js';
|
|
10
|
+
// Ink imports (lazy-loaded in main to avoid startup cost in tests)
|
|
11
|
+
let inkRenderFn = null;
|
|
12
|
+
let React = null;
|
|
13
|
+
let AppComponent = null;
|
|
14
|
+
export function createModuleState() {
|
|
53
15
|
return {
|
|
54
16
|
logScanActive: false,
|
|
55
17
|
statsPollActive: false,
|
|
@@ -59,8 +21,55 @@ function createModuleState() {
|
|
|
59
21
|
};
|
|
60
22
|
}
|
|
61
23
|
let moduleState = createModuleState();
|
|
24
|
+
// --- Reserved keys (built-in LIST mode bindings) ---
|
|
25
|
+
export const RESERVED_KEYS = new Set([
|
|
26
|
+
'j', 'k', 'b', 'd', 'w', 'e', 'x', 's', 'p', 'n', 'o', 'v',
|
|
27
|
+
'f', 'l', 't', 'q', 'G', 'g', '/',
|
|
28
|
+
'\r', '\x1b', '\x1b[A', '\x1b[B',
|
|
29
|
+
]);
|
|
30
|
+
// --- Variable substitution for custom actions ---
|
|
31
|
+
export function substituteVariables(template, vars) {
|
|
32
|
+
return template.replace(/\{(\w+)\}/g, (match, name) => {
|
|
33
|
+
return name in vars ? vars[name] : match;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
// --- Execute custom action ---
|
|
37
|
+
export function executeCustomAction(state, key) {
|
|
38
|
+
const action = state.config.customActions.find(a => a.key === key);
|
|
39
|
+
if (!action)
|
|
40
|
+
return false;
|
|
41
|
+
const entry = selectedEntry(state);
|
|
42
|
+
if (!entry)
|
|
43
|
+
return false;
|
|
44
|
+
const sk = statusKey(entry.file, entry.service);
|
|
45
|
+
const st = state.statuses.get(sk);
|
|
46
|
+
// Resolve worktree filesystem path from branch name
|
|
47
|
+
let worktreePath = '';
|
|
48
|
+
if (st?.worktree) {
|
|
49
|
+
const gitRoot = getGitRoot(path.dirname(entry.file));
|
|
50
|
+
if (gitRoot) {
|
|
51
|
+
const wts = listGitWorktrees(gitRoot);
|
|
52
|
+
const match = wts.find(wt => wt.branch === st.worktree);
|
|
53
|
+
if (match)
|
|
54
|
+
worktreePath = match.path;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const vars = {
|
|
58
|
+
service: entry.service,
|
|
59
|
+
file: entry.file,
|
|
60
|
+
workingDir: st?.workingDir || '',
|
|
61
|
+
worktree: st?.worktree || '',
|
|
62
|
+
worktreePath,
|
|
63
|
+
containerId: st?.id || '',
|
|
64
|
+
cwd: process.cwd(),
|
|
65
|
+
};
|
|
66
|
+
const cmd = substituteVariables(action.command, vars);
|
|
67
|
+
const child = exec(cmd, { stdio: 'ignore' });
|
|
68
|
+
child.unref();
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
62
71
|
// --- Config ---
|
|
63
|
-
function loadConfig() {
|
|
72
|
+
export function loadConfig() {
|
|
64
73
|
const defaults = {
|
|
65
74
|
composeFiles: [],
|
|
66
75
|
pollInterval: 3000,
|
|
@@ -76,10 +85,11 @@ function loadConfig() {
|
|
|
76
85
|
memWarnThreshold: 512,
|
|
77
86
|
memDangerThreshold: 1024,
|
|
78
87
|
theme: 'auto',
|
|
88
|
+
customActions: [],
|
|
79
89
|
};
|
|
80
|
-
const configPath =
|
|
81
|
-
if (
|
|
82
|
-
const raw = JSON.parse(
|
|
90
|
+
const configPath = path.join(process.cwd(), 'recomposable.json');
|
|
91
|
+
if (fs.existsSync(configPath)) {
|
|
92
|
+
const raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
83
93
|
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
84
94
|
if (Array.isArray(raw.composeFiles) && raw.composeFiles.every((f) => typeof f === 'string')) {
|
|
85
95
|
defaults.composeFiles = raw.composeFiles;
|
|
@@ -108,6 +118,39 @@ function loadConfig() {
|
|
|
108
118
|
if (raw.theme === 'light' || raw.theme === 'dark' || raw.theme === 'auto') {
|
|
109
119
|
defaults.theme = raw.theme;
|
|
110
120
|
}
|
|
121
|
+
// Parse customActions
|
|
122
|
+
if (Array.isArray(raw.customActions)) {
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
for (const entry of raw.customActions) {
|
|
125
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
126
|
+
process.stderr.write(`customActions: skipping invalid entry (not an object)\n`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const { key, label, command } = entry;
|
|
130
|
+
if (typeof key !== 'string' || key.length !== 1) {
|
|
131
|
+
process.stderr.write(`customActions: skipping entry with invalid key "${key}" (must be a single character)\n`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (typeof label !== 'string' || !label) {
|
|
135
|
+
process.stderr.write(`customActions: skipping entry "${key}" with empty label\n`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (typeof command !== 'string' || !command) {
|
|
139
|
+
process.stderr.write(`customActions: skipping entry "${key}" with empty command\n`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (RESERVED_KEYS.has(key)) {
|
|
143
|
+
process.stderr.write(`customActions: skipping entry "${key}" — conflicts with built-in key\n`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (seen.has(key)) {
|
|
147
|
+
process.stderr.write(`customActions: skipping duplicate key "${key}"\n`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
seen.add(key);
|
|
151
|
+
defaults.customActions.push({ key, label, command });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
111
154
|
}
|
|
112
155
|
}
|
|
113
156
|
const args = process.argv.slice(2);
|
|
@@ -127,15 +170,15 @@ function loadConfig() {
|
|
|
127
170
|
return defaults;
|
|
128
171
|
}
|
|
129
172
|
// --- Service Discovery ---
|
|
130
|
-
function discoverServices(config) {
|
|
173
|
+
export function discoverServices(config) {
|
|
131
174
|
const groups = [];
|
|
132
175
|
for (const file of config.composeFiles) {
|
|
133
|
-
const resolved =
|
|
134
|
-
const label =
|
|
176
|
+
const resolved = path.resolve(file);
|
|
177
|
+
const label = path.basename(file, path.extname(file)).replace(/^docker-compose\.?/, '') || path.basename(file);
|
|
135
178
|
let services = [];
|
|
136
179
|
let error = null;
|
|
137
180
|
try {
|
|
138
|
-
services =
|
|
181
|
+
services = listServices(resolved);
|
|
139
182
|
}
|
|
140
183
|
catch (e) {
|
|
141
184
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -146,26 +189,31 @@ function discoverServices(config) {
|
|
|
146
189
|
return groups;
|
|
147
190
|
}
|
|
148
191
|
// --- Status Polling ---
|
|
149
|
-
function pollStatuses(state) {
|
|
192
|
+
export function pollStatuses(state) {
|
|
193
|
+
pollStatusesAsync(state).catch(() => { });
|
|
194
|
+
}
|
|
195
|
+
async function pollStatusesAsync(state) {
|
|
150
196
|
// Collect services by their effective file (may differ from group file due to worktree overrides)
|
|
151
197
|
const fileToServices = new Map();
|
|
152
198
|
for (const group of state.groups) {
|
|
153
199
|
if (group.error)
|
|
154
200
|
continue;
|
|
155
201
|
for (const service of group.services) {
|
|
156
|
-
const sk =
|
|
157
|
-
const file = (
|
|
202
|
+
const sk = statusKey(group.file, service);
|
|
203
|
+
const { file, projectName } = getComposeTarget(state, group.file, service);
|
|
158
204
|
if (!fileToServices.has(file))
|
|
159
205
|
fileToServices.set(file, []);
|
|
160
|
-
fileToServices.get(file).push({ sk, service });
|
|
206
|
+
fileToServices.get(file).push({ sk, service, projectName });
|
|
161
207
|
}
|
|
162
208
|
}
|
|
163
|
-
|
|
164
|
-
const statuses = (
|
|
209
|
+
const results = await Promise.all([...fileToServices.entries()].map(async ([file, services]) => {
|
|
210
|
+
const statuses = await getStatusesAsync(file, services[0].projectName);
|
|
211
|
+
return { services, statuses };
|
|
212
|
+
}));
|
|
213
|
+
for (const { services, statuses } of results) {
|
|
165
214
|
const serviceSet = new Set(services.map(s => s.service));
|
|
166
215
|
for (const [svc, st] of statuses) {
|
|
167
216
|
if (serviceSet.has(svc)) {
|
|
168
|
-
// Store under the original statusKey (group.file based)
|
|
169
217
|
const match = services.find(s => s.service === svc);
|
|
170
218
|
if (match)
|
|
171
219
|
state.statuses.set(match.sk, st);
|
|
@@ -173,8 +221,10 @@ function pollStatuses(state) {
|
|
|
173
221
|
}
|
|
174
222
|
}
|
|
175
223
|
detectMultipleWorktrees(state);
|
|
224
|
+
if (state.mode === MODE.LIST)
|
|
225
|
+
render(state);
|
|
176
226
|
}
|
|
177
|
-
function detectMultipleWorktrees(state) {
|
|
227
|
+
export function detectMultipleWorktrees(state) {
|
|
178
228
|
const worktrees = new Set();
|
|
179
229
|
for (const st of state.statuses.values()) {
|
|
180
230
|
if (st.state === 'running' && st.worktree) {
|
|
@@ -184,7 +234,7 @@ function detectMultipleWorktrees(state) {
|
|
|
184
234
|
state.showWorktreeColumn = worktrees.size > 1;
|
|
185
235
|
}
|
|
186
236
|
// --- Log Pattern Scanning ---
|
|
187
|
-
function pollLogCounts(state) {
|
|
237
|
+
export function pollLogCounts(state) {
|
|
188
238
|
if (moduleState.logScanActive)
|
|
189
239
|
return;
|
|
190
240
|
const scanPatterns = state.config.logScanPatterns || [];
|
|
@@ -196,7 +246,7 @@ function pollLogCounts(state) {
|
|
|
196
246
|
if (group.error)
|
|
197
247
|
continue;
|
|
198
248
|
for (const service of group.services) {
|
|
199
|
-
const sk =
|
|
249
|
+
const sk = statusKey(group.file, service);
|
|
200
250
|
const st = state.statuses.get(sk);
|
|
201
251
|
if (!st || st.state !== 'running' || !st.id)
|
|
202
252
|
continue;
|
|
@@ -208,7 +258,7 @@ function pollLogCounts(state) {
|
|
|
208
258
|
moduleState.logScanActive = true;
|
|
209
259
|
let remaining = toScan.length;
|
|
210
260
|
for (const { sk, containerId } of toScan) {
|
|
211
|
-
const child =
|
|
261
|
+
const child = fetchContainerLogs(containerId, tailLines);
|
|
212
262
|
let output = '';
|
|
213
263
|
child.stdout.on('data', (d) => { output += d.toString(); });
|
|
214
264
|
child.stderr.on('data', (d) => { output += d.toString(); });
|
|
@@ -231,7 +281,7 @@ function pollLogCounts(state) {
|
|
|
231
281
|
remaining--;
|
|
232
282
|
if (remaining === 0) {
|
|
233
283
|
moduleState.logScanActive = false;
|
|
234
|
-
if (state.mode ===
|
|
284
|
+
if (state.mode === MODE.LIST)
|
|
235
285
|
throttledRender(state);
|
|
236
286
|
}
|
|
237
287
|
});
|
|
@@ -239,14 +289,14 @@ function pollLogCounts(state) {
|
|
|
239
289
|
remaining--;
|
|
240
290
|
if (remaining === 0) {
|
|
241
291
|
moduleState.logScanActive = false;
|
|
242
|
-
if (state.mode ===
|
|
292
|
+
if (state.mode === MODE.LIST)
|
|
243
293
|
throttledRender(state);
|
|
244
294
|
}
|
|
245
295
|
});
|
|
246
296
|
}
|
|
247
297
|
}
|
|
248
298
|
// --- Stats Polling ---
|
|
249
|
-
function pollContainerStats(state) {
|
|
299
|
+
export function pollContainerStats(state) {
|
|
250
300
|
if (moduleState.statsPollActive)
|
|
251
301
|
return;
|
|
252
302
|
const idToKey = new Map();
|
|
@@ -254,7 +304,7 @@ function pollContainerStats(state) {
|
|
|
254
304
|
if (group.error)
|
|
255
305
|
continue;
|
|
256
306
|
for (const service of group.services) {
|
|
257
|
-
const sk =
|
|
307
|
+
const sk = statusKey(group.file, service);
|
|
258
308
|
const st = state.statuses.get(sk);
|
|
259
309
|
if (!st || st.state !== 'running' || !st.id)
|
|
260
310
|
continue;
|
|
@@ -265,7 +315,7 @@ function pollContainerStats(state) {
|
|
|
265
315
|
if (ids.length === 0)
|
|
266
316
|
return;
|
|
267
317
|
moduleState.statsPollActive = true;
|
|
268
|
-
const child =
|
|
318
|
+
const child = fetchContainerStats(ids);
|
|
269
319
|
let output = '';
|
|
270
320
|
child.stdout.on('data', (d) => { output += d.toString(); });
|
|
271
321
|
child.stderr.on('data', () => { });
|
|
@@ -275,7 +325,7 @@ function pollContainerStats(state) {
|
|
|
275
325
|
for (const line of output.trim().split('\n')) {
|
|
276
326
|
if (!line.trim())
|
|
277
327
|
continue;
|
|
278
|
-
const parsed =
|
|
328
|
+
const parsed = parseStatsLine(line);
|
|
279
329
|
if (!parsed)
|
|
280
330
|
continue;
|
|
281
331
|
let sk = null;
|
|
@@ -305,7 +355,7 @@ function pollContainerStats(state) {
|
|
|
305
355
|
memUsageBytes: memSum / hist.count,
|
|
306
356
|
});
|
|
307
357
|
}
|
|
308
|
-
if (state.mode ===
|
|
358
|
+
if (state.mode === MODE.LIST)
|
|
309
359
|
throttledRender(state);
|
|
310
360
|
});
|
|
311
361
|
child.on('error', () => {
|
|
@@ -313,21 +363,27 @@ function pollContainerStats(state) {
|
|
|
313
363
|
});
|
|
314
364
|
}
|
|
315
365
|
// --- Rendering ---
|
|
316
|
-
function render(state) {
|
|
366
|
+
export function render(state) {
|
|
367
|
+
// If Ink is active, trigger a React re-render
|
|
368
|
+
if (state._inkRender) {
|
|
369
|
+
state._inkRender();
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
// Fallback: ANSI string rendering (used in tests and pre-Ink mode)
|
|
317
373
|
let view = '';
|
|
318
|
-
if (state.mode ===
|
|
319
|
-
view =
|
|
374
|
+
if (state.mode === MODE.LIST) {
|
|
375
|
+
view = renderListView(state);
|
|
320
376
|
}
|
|
321
|
-
else if (state.mode ===
|
|
322
|
-
view =
|
|
377
|
+
else if (state.mode === MODE.LOGS) {
|
|
378
|
+
view = renderLogView(state);
|
|
323
379
|
}
|
|
324
|
-
else if (state.mode ===
|
|
325
|
-
view =
|
|
380
|
+
else if (state.mode === MODE.EXEC) {
|
|
381
|
+
view = renderExecView(state);
|
|
326
382
|
}
|
|
327
383
|
// View functions already embed CLEAR_EOL per line; just clear below last line
|
|
328
|
-
process.stdout.write(
|
|
384
|
+
process.stdout.write(clearScreen() + view + CLEAR_EOL + CLEAR_EOS);
|
|
329
385
|
}
|
|
330
|
-
function stripAnsi(str) {
|
|
386
|
+
export function stripAnsi(str) {
|
|
331
387
|
return str.replace(
|
|
332
388
|
// CSI sequences: \x1b[ ... letter
|
|
333
389
|
// OSC sequences: \x1b] ... BEL or \x1b] ... ST
|
|
@@ -335,7 +391,7 @@ function stripAnsi(str) {
|
|
|
335
391
|
// Two-byte escape sequences: \x1b + any char
|
|
336
392
|
/\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[P_^X][^\x1b]*(?:\x1b\\|\x07)|\x1b[^[\]P_^X]/g, '');
|
|
337
393
|
}
|
|
338
|
-
function throttledRender(state) {
|
|
394
|
+
export function throttledRender(state) {
|
|
339
395
|
const now = Date.now();
|
|
340
396
|
const elapsed = now - moduleState.lastRenderTime;
|
|
341
397
|
if (elapsed >= 150) {
|
|
@@ -351,52 +407,91 @@ function throttledRender(state) {
|
|
|
351
407
|
}
|
|
352
408
|
}
|
|
353
409
|
// --- Actions ---
|
|
354
|
-
function
|
|
355
|
-
|
|
410
|
+
function ensureAnimTimer(state) {
|
|
411
|
+
if (state.animTimer)
|
|
412
|
+
return;
|
|
413
|
+
state.animDots = 0;
|
|
414
|
+
state.animTimer = setInterval(() => {
|
|
415
|
+
state.animDots = (state.animDots + 1) % 3;
|
|
416
|
+
// Only re-render if there's something animating
|
|
417
|
+
const hasActivity = state.bottomLogLoading
|
|
418
|
+
|| state.rebuilding.size > 0
|
|
419
|
+
|| state.restarting.size > 0
|
|
420
|
+
|| state.stopping.size > 0
|
|
421
|
+
|| state.starting.size > 0
|
|
422
|
+
|| state.cascading.size > 0;
|
|
423
|
+
if (hasActivity && state.mode === MODE.LIST)
|
|
424
|
+
render(state);
|
|
425
|
+
}, 500);
|
|
426
|
+
}
|
|
427
|
+
function startBottomLogLoadingAnim(state) {
|
|
428
|
+
state.bottomLogLoading = true;
|
|
429
|
+
ensureAnimTimer(state);
|
|
430
|
+
}
|
|
431
|
+
function stopBottomLogLoadingAnim(state) {
|
|
432
|
+
state.bottomLogLoading = false;
|
|
433
|
+
}
|
|
434
|
+
export function updateSelectedLogs(state) {
|
|
435
|
+
const entry = selectedEntry(state);
|
|
356
436
|
if (!entry)
|
|
357
437
|
return;
|
|
358
|
-
const sk =
|
|
438
|
+
const sk = statusKey(entry.file, entry.service);
|
|
359
439
|
if (state.selectedLogKey === sk)
|
|
360
440
|
return;
|
|
361
|
-
|
|
362
|
-
state.bottomSearchActive = false;
|
|
363
|
-
clearBottomSearch(state);
|
|
441
|
+
// Cancel any pending log fetch
|
|
364
442
|
if (moduleState.logFetchTimer) {
|
|
365
443
|
clearTimeout(moduleState.logFetchTimer);
|
|
366
444
|
moduleState.logFetchTimer = null;
|
|
367
445
|
}
|
|
446
|
+
// Immediately kill old tail so it stops pumping data during scrolling
|
|
368
447
|
if (state.selectedLogKey) {
|
|
369
|
-
const
|
|
448
|
+
const oldKey = state.selectedLogKey;
|
|
449
|
+
const oldInfo = state.bottomLogLines.get(oldKey);
|
|
370
450
|
if (oldInfo && (oldInfo.action === 'logs' || oldInfo.action === 'started')) {
|
|
371
|
-
if (!state.rebuilding.has(
|
|
372
|
-
state.
|
|
373
|
-
|
|
374
|
-
state.bottomLogTails.
|
|
375
|
-
state.bottomLogTails.delete(state.selectedLogKey);
|
|
451
|
+
if (!state.rebuilding.has(oldKey) && !state.restarting.has(oldKey)) {
|
|
452
|
+
if (state.bottomLogTails.has(oldKey)) {
|
|
453
|
+
state.bottomLogTails.get(oldKey).kill('SIGTERM');
|
|
454
|
+
state.bottomLogTails.delete(oldKey);
|
|
376
455
|
}
|
|
456
|
+
state.bottomLogLines.delete(oldKey);
|
|
377
457
|
}
|
|
378
458
|
}
|
|
379
459
|
}
|
|
380
460
|
state.selectedLogKey = sk;
|
|
381
|
-
|
|
461
|
+
state.bottomSearchQuery = '';
|
|
462
|
+
state.bottomSearchActive = false;
|
|
463
|
+
clearBottomSearch(state);
|
|
464
|
+
// If we already have cached log data for this service, show it immediately
|
|
465
|
+
if (state.bottomLogLines.has(sk)) {
|
|
466
|
+
stopBottomLogLoadingAnim(state);
|
|
382
467
|
return;
|
|
383
|
-
|
|
384
|
-
|
|
468
|
+
}
|
|
469
|
+
// Start animated "loading logs." indicator immediately
|
|
470
|
+
startBottomLogLoadingAnim(state);
|
|
471
|
+
// Debounce: only start loading logs after the user stops scrolling
|
|
472
|
+
const { file: effectiveFile, projectName } = getComposeTarget(state, entry.file, entry.service);
|
|
385
473
|
moduleState.logFetchTimer = setTimeout(() => {
|
|
386
474
|
moduleState.logFetchTimer = null;
|
|
387
|
-
|
|
388
|
-
|
|
475
|
+
// Bail if user scrolled away during the debounce
|
|
476
|
+
if (state.selectedLogKey !== sk)
|
|
477
|
+
return;
|
|
478
|
+
state.bottomLogLines.set(sk, { action: 'logs', service: entry.service, lines: [] });
|
|
479
|
+
startBottomLogTail(state, sk, effectiveFile, entry.service, projectName);
|
|
480
|
+
if (state.mode === MODE.LIST)
|
|
481
|
+
render(state);
|
|
482
|
+
}, 1200);
|
|
389
483
|
}
|
|
390
|
-
function startBottomLogTail(state, sk, file, service) {
|
|
484
|
+
async function startBottomLogTail(state, sk, file, service, projectName) {
|
|
391
485
|
if (state.bottomLogTails.has(sk)) {
|
|
392
486
|
state.bottomLogTails.get(sk).kill('SIGTERM');
|
|
393
487
|
state.bottomLogTails.delete(sk);
|
|
394
488
|
}
|
|
395
|
-
const containerId =
|
|
396
|
-
|
|
489
|
+
const containerId = await getContainerIdAsync(file, service, projectName);
|
|
490
|
+
// If the user scrolled away while we were waiting, bail out
|
|
491
|
+
if (!containerId || state.selectedLogKey !== sk)
|
|
397
492
|
return;
|
|
398
493
|
const maxLines = state.config.bottomLogCount || 10;
|
|
399
|
-
const logChild =
|
|
494
|
+
const logChild = tailContainerLogs(containerId, maxLines);
|
|
400
495
|
state.bottomLogTails.set(sk, logChild);
|
|
401
496
|
let buf = '';
|
|
402
497
|
const onData = (data) => {
|
|
@@ -412,25 +507,30 @@ function startBottomLogTail(state, sk, file, service) {
|
|
|
412
507
|
info.lines.push(...newLines);
|
|
413
508
|
if (info.lines.length > maxLines)
|
|
414
509
|
info.lines = info.lines.slice(-maxLines);
|
|
415
|
-
if (state.
|
|
510
|
+
if (state.bottomLogLoading)
|
|
511
|
+
stopBottomLogLoadingAnim(state);
|
|
512
|
+
if (state.mode === MODE.LIST)
|
|
416
513
|
throttledRender(state);
|
|
417
514
|
};
|
|
418
515
|
logChild.stdout.on('data', onData);
|
|
419
516
|
logChild.stderr.on('data', onData);
|
|
420
517
|
}
|
|
421
|
-
function doRebuild(state) {
|
|
422
|
-
const entry =
|
|
518
|
+
export function doRebuild(state) {
|
|
519
|
+
const entry = selectedEntry(state);
|
|
423
520
|
if (!entry)
|
|
424
521
|
return;
|
|
425
|
-
|
|
522
|
+
doRebuildEntry(state, entry);
|
|
523
|
+
}
|
|
524
|
+
export function doRebuildEntry(state, entry) {
|
|
525
|
+
const sk = statusKey(entry.file, entry.service);
|
|
426
526
|
if (state.rebuilding.has(sk))
|
|
427
527
|
return;
|
|
428
|
-
const effectiveFile = (
|
|
528
|
+
const { file: effectiveFile, projectName } = getComposeTarget(state, entry.file, entry.service);
|
|
429
529
|
if (state.bottomLogTails.has(sk)) {
|
|
430
530
|
state.bottomLogTails.get(sk).kill('SIGTERM');
|
|
431
531
|
state.bottomLogTails.delete(sk);
|
|
432
532
|
}
|
|
433
|
-
const child =
|
|
533
|
+
const child = rebuildService(effectiveFile, entry.service, { noCache: state.noCache, noDeps: state.noDeps }, projectName);
|
|
434
534
|
state.rebuilding.set(sk, child);
|
|
435
535
|
state.bottomLogLines.set(sk, { action: 'rebuilding', service: entry.service, lines: [] });
|
|
436
536
|
let lineBuf = '';
|
|
@@ -445,12 +545,12 @@ function doRebuild(state) {
|
|
|
445
545
|
if (newLines.length === 0)
|
|
446
546
|
return;
|
|
447
547
|
info.lines.push(...newLines);
|
|
448
|
-
if (state.mode ===
|
|
548
|
+
if (state.mode === MODE.LOGS && state.logBuildKey === sk) {
|
|
449
549
|
state.logLines.push(...newLines);
|
|
450
550
|
if (state.logAutoScroll)
|
|
451
551
|
throttledRender(state);
|
|
452
552
|
}
|
|
453
|
-
if (state.mode ===
|
|
553
|
+
if (state.mode === MODE.LIST)
|
|
454
554
|
throttledRender(state);
|
|
455
555
|
};
|
|
456
556
|
child.stdout.on('data', onData);
|
|
@@ -465,7 +565,7 @@ function doRebuild(state) {
|
|
|
465
565
|
if (code !== 0 && code !== null) {
|
|
466
566
|
if (info)
|
|
467
567
|
info.action = 'build_failed';
|
|
468
|
-
if (state.mode ===
|
|
568
|
+
if (state.mode === MODE.LIST)
|
|
469
569
|
render(state);
|
|
470
570
|
return;
|
|
471
571
|
}
|
|
@@ -474,24 +574,27 @@ function doRebuild(state) {
|
|
|
474
574
|
if (state.logBuildKey !== sk)
|
|
475
575
|
info.lines = [];
|
|
476
576
|
}
|
|
477
|
-
startBottomLogTail(state, sk, effectiveFile, entry.service);
|
|
478
|
-
if (state.mode ===
|
|
577
|
+
startBottomLogTail(state, sk, effectiveFile, entry.service, projectName);
|
|
578
|
+
if (state.mode === MODE.LIST)
|
|
479
579
|
render(state);
|
|
480
580
|
});
|
|
481
581
|
}
|
|
482
|
-
function doRestart(state) {
|
|
483
|
-
const entry =
|
|
582
|
+
export function doRestart(state) {
|
|
583
|
+
const entry = selectedEntry(state);
|
|
484
584
|
if (!entry)
|
|
485
585
|
return;
|
|
486
|
-
|
|
586
|
+
doRestartEntry(state, entry);
|
|
587
|
+
}
|
|
588
|
+
export function doRestartEntry(state, entry) {
|
|
589
|
+
const sk = statusKey(entry.file, entry.service);
|
|
487
590
|
if (state.restarting.has(sk) || state.rebuilding.has(sk))
|
|
488
591
|
return;
|
|
489
|
-
const effectiveFile = (
|
|
592
|
+
const { file: effectiveFile, projectName } = getComposeTarget(state, entry.file, entry.service);
|
|
490
593
|
if (state.bottomLogTails.has(sk)) {
|
|
491
594
|
state.bottomLogTails.get(sk).kill('SIGTERM');
|
|
492
595
|
state.bottomLogTails.delete(sk);
|
|
493
596
|
}
|
|
494
|
-
const child =
|
|
597
|
+
const child = restartService(effectiveFile, entry.service, projectName);
|
|
495
598
|
state.restarting.set(sk, child);
|
|
496
599
|
state.bottomLogLines.set(sk, { action: 'restarting', service: entry.service, lines: [] });
|
|
497
600
|
render(state);
|
|
@@ -504,7 +607,7 @@ function doRestart(state) {
|
|
|
504
607
|
if (code !== 0 && code !== null) {
|
|
505
608
|
if (info)
|
|
506
609
|
info.action = 'restart_failed';
|
|
507
|
-
if (state.mode ===
|
|
610
|
+
if (state.mode === MODE.LIST)
|
|
508
611
|
render(state);
|
|
509
612
|
return;
|
|
510
613
|
}
|
|
@@ -512,16 +615,19 @@ function doRestart(state) {
|
|
|
512
615
|
info.action = 'started';
|
|
513
616
|
info.lines = [];
|
|
514
617
|
}
|
|
515
|
-
startBottomLogTail(state, sk, effectiveFile, entry.service);
|
|
516
|
-
if (state.mode ===
|
|
618
|
+
startBottomLogTail(state, sk, effectiveFile, entry.service, projectName);
|
|
619
|
+
if (state.mode === MODE.LIST)
|
|
517
620
|
render(state);
|
|
518
621
|
});
|
|
519
622
|
}
|
|
520
|
-
function doStop(state) {
|
|
521
|
-
const entry =
|
|
623
|
+
export function doStop(state) {
|
|
624
|
+
const entry = selectedEntry(state);
|
|
522
625
|
if (!entry)
|
|
523
626
|
return;
|
|
524
|
-
|
|
627
|
+
doStopEntry(state, entry);
|
|
628
|
+
}
|
|
629
|
+
export function doStopEntry(state, entry) {
|
|
630
|
+
const sk = statusKey(entry.file, entry.service);
|
|
525
631
|
if (state.stopping.has(sk) || state.rebuilding.has(sk) || state.restarting.has(sk))
|
|
526
632
|
return;
|
|
527
633
|
const st = state.statuses.get(sk);
|
|
@@ -531,8 +637,8 @@ function doStop(state) {
|
|
|
531
637
|
state.bottomLogTails.get(sk).kill('SIGTERM');
|
|
532
638
|
state.bottomLogTails.delete(sk);
|
|
533
639
|
}
|
|
534
|
-
const effectiveFile = (
|
|
535
|
-
const child =
|
|
640
|
+
const { file: effectiveFile, projectName } = getComposeTarget(state, entry.file, entry.service);
|
|
641
|
+
const child = stopService(effectiveFile, entry.service, projectName);
|
|
536
642
|
state.stopping.set(sk, child);
|
|
537
643
|
state.bottomLogLines.set(sk, { action: 'stopping', service: entry.service, lines: [] });
|
|
538
644
|
render(state);
|
|
@@ -547,22 +653,25 @@ function doStop(state) {
|
|
|
547
653
|
state.bottomLogLines.delete(sk);
|
|
548
654
|
}
|
|
549
655
|
pollStatuses(state);
|
|
550
|
-
if (state.mode ===
|
|
656
|
+
if (state.mode === MODE.LIST)
|
|
551
657
|
render(state);
|
|
552
658
|
});
|
|
553
659
|
}
|
|
554
|
-
function doStart(state) {
|
|
555
|
-
const entry =
|
|
660
|
+
export function doStart(state) {
|
|
661
|
+
const entry = selectedEntry(state);
|
|
556
662
|
if (!entry)
|
|
557
663
|
return;
|
|
558
|
-
|
|
664
|
+
doStartEntry(state, entry);
|
|
665
|
+
}
|
|
666
|
+
export function doStartEntry(state, entry) {
|
|
667
|
+
const sk = statusKey(entry.file, entry.service);
|
|
559
668
|
if (state.starting.has(sk) || state.rebuilding.has(sk) || state.restarting.has(sk) || state.stopping.has(sk))
|
|
560
669
|
return;
|
|
561
670
|
const st = state.statuses.get(sk);
|
|
562
671
|
if (st && st.state === 'running')
|
|
563
672
|
return;
|
|
564
|
-
const effectiveFile = (
|
|
565
|
-
const child =
|
|
673
|
+
const { file: effectiveFile, projectName } = getComposeTarget(state, entry.file, entry.service);
|
|
674
|
+
const child = startService(effectiveFile, entry.service, projectName);
|
|
566
675
|
state.starting.set(sk, child);
|
|
567
676
|
state.bottomLogLines.set(sk, { action: 'starting', service: entry.service, lines: [] });
|
|
568
677
|
render(state);
|
|
@@ -573,7 +682,7 @@ function doStart(state) {
|
|
|
573
682
|
if (code !== 0 && code !== null) {
|
|
574
683
|
if (info)
|
|
575
684
|
info.action = 'start_failed';
|
|
576
|
-
if (state.mode ===
|
|
685
|
+
if (state.mode === MODE.LIST)
|
|
577
686
|
render(state);
|
|
578
687
|
return;
|
|
579
688
|
}
|
|
@@ -581,60 +690,64 @@ function doStart(state) {
|
|
|
581
690
|
info.action = 'started';
|
|
582
691
|
info.lines = [];
|
|
583
692
|
}
|
|
584
|
-
startBottomLogTail(state, sk, effectiveFile, entry.service);
|
|
585
|
-
if (state.mode ===
|
|
693
|
+
startBottomLogTail(state, sk, effectiveFile, entry.service, projectName);
|
|
694
|
+
if (state.mode === MODE.LIST)
|
|
586
695
|
render(state);
|
|
587
696
|
});
|
|
588
697
|
}
|
|
589
698
|
// --- Worktree Switching ---
|
|
590
|
-
function mapComposeFileToWorktree(composeFile, targetWorktreePath) {
|
|
591
|
-
const resolved =
|
|
592
|
-
const dir =
|
|
593
|
-
const gitRoot =
|
|
699
|
+
export function mapComposeFileToWorktree(composeFile, targetWorktreePath) {
|
|
700
|
+
const resolved = path.resolve(composeFile);
|
|
701
|
+
const dir = path.dirname(resolved);
|
|
702
|
+
const gitRoot = getGitRoot(dir);
|
|
594
703
|
if (!gitRoot)
|
|
595
704
|
return null;
|
|
596
|
-
const relPath =
|
|
597
|
-
const newFile =
|
|
705
|
+
const relPath = path.relative(gitRoot, resolved);
|
|
706
|
+
const newFile = path.join(targetWorktreePath, relPath);
|
|
598
707
|
try {
|
|
599
|
-
|
|
708
|
+
fs.accessSync(newFile);
|
|
600
709
|
return newFile;
|
|
601
710
|
}
|
|
602
711
|
catch {
|
|
603
712
|
return null;
|
|
604
713
|
}
|
|
605
714
|
}
|
|
606
|
-
function openWorktreePicker(state) {
|
|
607
|
-
const entry =
|
|
715
|
+
export function openWorktreePicker(state) {
|
|
716
|
+
const entry = selectedEntry(state);
|
|
608
717
|
if (!entry)
|
|
609
718
|
return;
|
|
610
|
-
const sk =
|
|
719
|
+
const sk = statusKey(entry.file, entry.service);
|
|
611
720
|
if (state.rebuilding.has(sk) || state.restarting.has(sk) || state.stopping.has(sk) || state.starting.has(sk) || state.cascading.has(sk))
|
|
612
721
|
return;
|
|
613
|
-
const composeDir =
|
|
614
|
-
const worktrees =
|
|
722
|
+
const composeDir = path.dirname(path.resolve(entry.file));
|
|
723
|
+
const worktrees = listGitWorktrees(composeDir);
|
|
615
724
|
if (worktrees.length <= 1) {
|
|
616
725
|
state.bottomLogLines.set(sk, { action: 'switch_failed', service: entry.service, lines: ['no other worktrees available — use `git worktree add` to create one'] });
|
|
617
726
|
state.showBottomLogs = true;
|
|
618
727
|
render(state);
|
|
619
728
|
return;
|
|
620
729
|
}
|
|
621
|
-
|
|
730
|
+
// Determine current worktree from container status, not original file location
|
|
731
|
+
const st = state.statuses.get(sk);
|
|
732
|
+
const containerBranch = st?.worktree || null;
|
|
733
|
+
const currentWorktree = containerBranch ? worktrees.find(w => w.branch === containerBranch) : null;
|
|
734
|
+
const currentPath = currentWorktree?.path || getGitRoot(composeDir);
|
|
622
735
|
state.worktreePickerEntries = worktrees;
|
|
623
736
|
state.worktreePickerActive = true;
|
|
624
|
-
state.worktreePickerCurrentPath =
|
|
737
|
+
state.worktreePickerCurrentPath = currentPath;
|
|
625
738
|
// Pre-select first non-current worktree
|
|
626
|
-
const currentIdx =
|
|
739
|
+
const currentIdx = currentPath ? worktrees.findIndex(w => w.path === currentPath) : -1;
|
|
627
740
|
const firstOther = worktrees.findIndex((_, i) => i !== currentIdx);
|
|
628
741
|
state.worktreePickerCursor = firstOther >= 0 ? firstOther : 0;
|
|
629
742
|
state.showBottomLogs = true;
|
|
630
743
|
render(state);
|
|
631
744
|
}
|
|
632
|
-
function doWorktreeSwitch(state, targetWorktree) {
|
|
633
|
-
const entry =
|
|
745
|
+
export function doWorktreeSwitch(state, targetWorktree) {
|
|
746
|
+
const entry = selectedEntry(state);
|
|
634
747
|
if (!entry)
|
|
635
748
|
return;
|
|
636
749
|
const service = entry.service;
|
|
637
|
-
const sk =
|
|
750
|
+
const sk = statusKey(entry.file, service);
|
|
638
751
|
// Close picker
|
|
639
752
|
state.worktreePickerActive = false;
|
|
640
753
|
state.worktreePickerEntries = [];
|
|
@@ -649,17 +762,21 @@ function doWorktreeSwitch(state, targetWorktree) {
|
|
|
649
762
|
render(state);
|
|
650
763
|
return;
|
|
651
764
|
}
|
|
652
|
-
// If target is the same as current effective file, nothing to do
|
|
653
|
-
const currentEffective =
|
|
654
|
-
|
|
765
|
+
// If target is the same as current effective file AND container is on the target branch, nothing to do
|
|
766
|
+
const currentEffective = getEffectiveFile(state, entry.file, service);
|
|
767
|
+
const currentSt = state.statuses.get(sk);
|
|
768
|
+
const containerBranch = currentSt?.worktree || null;
|
|
769
|
+
const alreadyOnTarget = path.resolve(newFile) === path.resolve(currentEffective)
|
|
770
|
+
&& (!containerBranch || containerBranch === targetWorktree.branch);
|
|
771
|
+
if (alreadyOnTarget) {
|
|
655
772
|
render(state);
|
|
656
773
|
return;
|
|
657
774
|
}
|
|
658
775
|
// Validate service exists in target compose file
|
|
659
|
-
if (!
|
|
776
|
+
if (!validateServiceInComposeFile(newFile, service)) {
|
|
660
777
|
state.bottomLogLines.set(sk, {
|
|
661
778
|
action: 'switch_failed', service,
|
|
662
|
-
lines: [`service "${service}" not found in ${
|
|
779
|
+
lines: [`service "${service}" not found in ${path.basename(newFile)} on branch "${targetWorktree.branch}"`],
|
|
663
780
|
});
|
|
664
781
|
render(state);
|
|
665
782
|
return;
|
|
@@ -667,6 +784,12 @@ function doWorktreeSwitch(state, targetWorktree) {
|
|
|
667
784
|
// Show switching progress
|
|
668
785
|
state.bottomLogLines.set(sk, { action: 'switching', service, lines: [`switching to worktree "${targetWorktree.branch}"...`] });
|
|
669
786
|
render(state);
|
|
787
|
+
// Project name always derived from original file for consistency
|
|
788
|
+
const origProjectName = composeProjectName(entry.file);
|
|
789
|
+
// Current effective file might also be a worktree override — use original project name
|
|
790
|
+
const currentProjectName = currentEffective !== entry.file ? origProjectName : undefined;
|
|
791
|
+
// New file is from the target worktree — use original project name (unless switching back to original)
|
|
792
|
+
const newProjectName = newFile !== entry.file ? origProjectName : undefined;
|
|
670
793
|
const performSwitch = () => {
|
|
671
794
|
// Store the worktree override (or remove if switching back to original)
|
|
672
795
|
if (newFile === entry.file) {
|
|
@@ -678,7 +801,7 @@ function doWorktreeSwitch(state, targetWorktree) {
|
|
|
678
801
|
// Update bottomLogLines to show rebuild
|
|
679
802
|
state.bottomLogLines.set(sk, { action: 'switching', service, lines: [`rebuilding in worktree "${targetWorktree.branch}"...`] });
|
|
680
803
|
// Rebuild in new worktree
|
|
681
|
-
const child =
|
|
804
|
+
const child = rebuildService(newFile, service, { noCache: state.noCache, noDeps: state.noDeps }, newProjectName);
|
|
682
805
|
state.rebuilding.set(sk, child);
|
|
683
806
|
let lineBuf = '';
|
|
684
807
|
const onData = (data) => {
|
|
@@ -692,7 +815,7 @@ function doWorktreeSwitch(state, targetWorktree) {
|
|
|
692
815
|
if (newLines.length === 0)
|
|
693
816
|
return;
|
|
694
817
|
info.lines.push(...newLines);
|
|
695
|
-
if (state.mode ===
|
|
818
|
+
if (state.mode === MODE.LIST)
|
|
696
819
|
throttledRender(state);
|
|
697
820
|
};
|
|
698
821
|
child.stdout.on('data', onData);
|
|
@@ -707,7 +830,7 @@ function doWorktreeSwitch(state, targetWorktree) {
|
|
|
707
830
|
if (code !== 0 && code !== null) {
|
|
708
831
|
if (info)
|
|
709
832
|
info.action = 'build_failed';
|
|
710
|
-
if (state.mode ===
|
|
833
|
+
if (state.mode === MODE.LIST)
|
|
711
834
|
render(state);
|
|
712
835
|
return;
|
|
713
836
|
}
|
|
@@ -715,8 +838,8 @@ function doWorktreeSwitch(state, targetWorktree) {
|
|
|
715
838
|
info.action = 'started';
|
|
716
839
|
info.lines = [];
|
|
717
840
|
}
|
|
718
|
-
startBottomLogTail(state, sk, newFile, service);
|
|
719
|
-
if (state.mode ===
|
|
841
|
+
startBottomLogTail(state, sk, newFile, service, newProjectName);
|
|
842
|
+
if (state.mode === MODE.LIST)
|
|
720
843
|
render(state);
|
|
721
844
|
});
|
|
722
845
|
};
|
|
@@ -727,7 +850,7 @@ function doWorktreeSwitch(state, targetWorktree) {
|
|
|
727
850
|
state.bottomLogTails.get(sk).kill('SIGTERM');
|
|
728
851
|
state.bottomLogTails.delete(sk);
|
|
729
852
|
}
|
|
730
|
-
const stopChild =
|
|
853
|
+
const stopChild = stopService(currentEffective, service, currentProjectName);
|
|
731
854
|
state.stopping.set(sk, stopChild);
|
|
732
855
|
render(state);
|
|
733
856
|
stopChild.on('close', () => {
|
|
@@ -739,12 +862,138 @@ function doWorktreeSwitch(state, targetWorktree) {
|
|
|
739
862
|
performSwitch();
|
|
740
863
|
}
|
|
741
864
|
}
|
|
865
|
+
export function openWorktreePickerMulti(state) {
|
|
866
|
+
// Use the first selected service to determine worktrees
|
|
867
|
+
const firstSk = [...state.multiSelected][0];
|
|
868
|
+
const firstEntry = state.flatList.find(e => statusKey(e.file, e.service) === firstSk);
|
|
869
|
+
if (!firstEntry)
|
|
870
|
+
return;
|
|
871
|
+
const composeDir = path.dirname(path.resolve(firstEntry.file));
|
|
872
|
+
const worktrees = listGitWorktrees(composeDir);
|
|
873
|
+
if (worktrees.length <= 1) {
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
// Determine current worktree from container status, not original file location
|
|
877
|
+
const firstSt = state.statuses.get(firstSk);
|
|
878
|
+
const containerBranch = firstSt?.worktree || null;
|
|
879
|
+
const currentWorktree = containerBranch ? worktrees.find(w => w.branch === containerBranch) : null;
|
|
880
|
+
const currentPath = currentWorktree?.path || getGitRoot(composeDir);
|
|
881
|
+
state.worktreePickerEntries = worktrees;
|
|
882
|
+
state.worktreePickerActive = true;
|
|
883
|
+
state.worktreePickerCurrentPath = currentPath;
|
|
884
|
+
const currentIdx = currentPath ? worktrees.findIndex(w => w.path === currentPath) : -1;
|
|
885
|
+
const firstOther = worktrees.findIndex((_, i) => i !== currentIdx);
|
|
886
|
+
state.worktreePickerCursor = firstOther >= 0 ? firstOther : 0;
|
|
887
|
+
state.showBottomLogs = true;
|
|
888
|
+
render(state);
|
|
889
|
+
}
|
|
890
|
+
export function doWorktreeSwitchMulti(state, targetWorktree) {
|
|
891
|
+
// Close picker
|
|
892
|
+
state.worktreePickerActive = false;
|
|
893
|
+
state.worktreePickerEntries = [];
|
|
894
|
+
state.worktreePickerCursor = 0;
|
|
895
|
+
for (const mSk of state.multiSelected) {
|
|
896
|
+
const entry = state.flatList.find(e => statusKey(e.file, e.service) === mSk);
|
|
897
|
+
if (!entry)
|
|
898
|
+
continue;
|
|
899
|
+
if (state.rebuilding.has(mSk) || state.restarting.has(mSk) || state.stopping.has(mSk) || state.starting.has(mSk) || state.cascading.has(mSk))
|
|
900
|
+
continue;
|
|
901
|
+
doWorktreeSwitchEntry(state, entry, targetWorktree);
|
|
902
|
+
}
|
|
903
|
+
render(state);
|
|
904
|
+
}
|
|
905
|
+
function doWorktreeSwitchEntry(state, entry, targetWorktree) {
|
|
906
|
+
const service = entry.service;
|
|
907
|
+
const sk = statusKey(entry.file, service);
|
|
908
|
+
const newFile = mapComposeFileToWorktree(entry.file, targetWorktree.path);
|
|
909
|
+
if (!newFile)
|
|
910
|
+
return;
|
|
911
|
+
const currentEffective = getEffectiveFile(state, entry.file, service);
|
|
912
|
+
// Also check the container's actual worktree — overrides are lost on restart
|
|
913
|
+
const currentSt = state.statuses.get(sk);
|
|
914
|
+
const containerBranch = currentSt?.worktree || null;
|
|
915
|
+
const alreadyOnTarget = path.resolve(newFile) === path.resolve(currentEffective)
|
|
916
|
+
&& (!containerBranch || containerBranch === targetWorktree.branch);
|
|
917
|
+
if (alreadyOnTarget)
|
|
918
|
+
return;
|
|
919
|
+
if (!validateServiceInComposeFile(newFile, service))
|
|
920
|
+
return;
|
|
921
|
+
state.bottomLogLines.set(sk, { action: 'switching', service, lines: [`switching to worktree "${targetWorktree.branch}"...`] });
|
|
922
|
+
const origProjectName = composeProjectName(entry.file);
|
|
923
|
+
const currentProjectName = currentEffective !== entry.file ? origProjectName : undefined;
|
|
924
|
+
const newProjectName = newFile !== entry.file ? origProjectName : undefined;
|
|
925
|
+
const performSwitch = () => {
|
|
926
|
+
if (newFile === entry.file) {
|
|
927
|
+
state.worktreeOverrides.delete(sk);
|
|
928
|
+
}
|
|
929
|
+
else {
|
|
930
|
+
state.worktreeOverrides.set(sk, newFile);
|
|
931
|
+
}
|
|
932
|
+
state.bottomLogLines.set(sk, { action: 'switching', service, lines: [`rebuilding in worktree "${targetWorktree.branch}"...`] });
|
|
933
|
+
const child = rebuildService(newFile, service, { noCache: state.noCache, noDeps: state.noDeps }, newProjectName);
|
|
934
|
+
state.rebuilding.set(sk, child);
|
|
935
|
+
let lineBuf = '';
|
|
936
|
+
const onData = (data) => {
|
|
937
|
+
const info = state.bottomLogLines.get(sk);
|
|
938
|
+
if (!info)
|
|
939
|
+
return;
|
|
940
|
+
lineBuf += data.toString();
|
|
941
|
+
const parts = lineBuf.split(/\r?\n|\r/);
|
|
942
|
+
lineBuf = parts.pop();
|
|
943
|
+
const newLines = parts.filter(l => l.trim().length > 0).map(stripAnsi).filter(Boolean);
|
|
944
|
+
if (newLines.length === 0)
|
|
945
|
+
return;
|
|
946
|
+
info.lines.push(...newLines);
|
|
947
|
+
if (state.mode === MODE.LIST)
|
|
948
|
+
throttledRender(state);
|
|
949
|
+
};
|
|
950
|
+
child.stdout.on('data', onData);
|
|
951
|
+
child.stderr.on('data', onData);
|
|
952
|
+
child.on('close', (code) => {
|
|
953
|
+
state.rebuilding.delete(sk);
|
|
954
|
+
state.containerStatsHistory.delete(sk);
|
|
955
|
+
state.containerStats.delete(sk);
|
|
956
|
+
pollStatuses(state);
|
|
957
|
+
const info = state.bottomLogLines.get(sk);
|
|
958
|
+
if (code !== 0 && code !== null) {
|
|
959
|
+
if (info)
|
|
960
|
+
info.action = 'switch_failed';
|
|
961
|
+
if (state.mode === MODE.LIST)
|
|
962
|
+
render(state);
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
if (info) {
|
|
966
|
+
info.action = 'started';
|
|
967
|
+
info.lines = [];
|
|
968
|
+
}
|
|
969
|
+
startBottomLogTail(state, sk, newFile, service, newProjectName);
|
|
970
|
+
if (state.mode === MODE.LIST)
|
|
971
|
+
render(state);
|
|
972
|
+
});
|
|
973
|
+
};
|
|
974
|
+
const st = state.statuses.get(sk);
|
|
975
|
+
if (st && st.state === 'running') {
|
|
976
|
+
if (state.bottomLogTails.has(sk)) {
|
|
977
|
+
state.bottomLogTails.get(sk).kill('SIGTERM');
|
|
978
|
+
state.bottomLogTails.delete(sk);
|
|
979
|
+
}
|
|
980
|
+
const stopChild = stopService(currentEffective, service, currentProjectName);
|
|
981
|
+
state.stopping.set(sk, stopChild);
|
|
982
|
+
stopChild.on('close', () => {
|
|
983
|
+
state.stopping.delete(sk);
|
|
984
|
+
performSwitch();
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
else {
|
|
988
|
+
performSwitch();
|
|
989
|
+
}
|
|
990
|
+
}
|
|
742
991
|
// --- Watch ---
|
|
743
|
-
function doWatch(state) {
|
|
744
|
-
const entry =
|
|
992
|
+
export function doWatch(state) {
|
|
993
|
+
const entry = selectedEntry(state);
|
|
745
994
|
if (!entry)
|
|
746
995
|
return;
|
|
747
|
-
const sk =
|
|
996
|
+
const sk = statusKey(entry.file, entry.service);
|
|
748
997
|
// Toggle off if already watching
|
|
749
998
|
if (state.watching.has(sk)) {
|
|
750
999
|
state.watching.get(sk).kill('SIGTERM');
|
|
@@ -758,7 +1007,7 @@ function doWatch(state) {
|
|
|
758
1007
|
}
|
|
759
1008
|
// Check availability on first use
|
|
760
1009
|
if (state.watchAvailable === null) {
|
|
761
|
-
state.watchAvailable =
|
|
1010
|
+
state.watchAvailable = isWatchAvailable();
|
|
762
1011
|
}
|
|
763
1012
|
if (!state.watchAvailable) {
|
|
764
1013
|
state.bottomLogLines.set(sk, { action: 'watching', service: entry.service, lines: ['docker compose watch is not available (requires Docker Compose v2.22+)'] });
|
|
@@ -766,8 +1015,8 @@ function doWatch(state) {
|
|
|
766
1015
|
render(state);
|
|
767
1016
|
return;
|
|
768
1017
|
}
|
|
769
|
-
const effectiveFile = (
|
|
770
|
-
const child =
|
|
1018
|
+
const { file: effectiveFile, projectName } = getComposeTarget(state, entry.file, entry.service);
|
|
1019
|
+
const child = watchService(effectiveFile, entry.service, projectName);
|
|
771
1020
|
state.watching.set(sk, child);
|
|
772
1021
|
state.bottomLogLines.set(sk, { action: 'watching', service: entry.service, lines: [] });
|
|
773
1022
|
state.showBottomLogs = true;
|
|
@@ -786,25 +1035,25 @@ function doWatch(state) {
|
|
|
786
1035
|
info.lines.push(...newLines);
|
|
787
1036
|
if (info.lines.length > maxLines)
|
|
788
1037
|
info.lines = info.lines.slice(-maxLines);
|
|
789
|
-
if (state.mode ===
|
|
1038
|
+
if (state.mode === MODE.LIST)
|
|
790
1039
|
throttledRender(state);
|
|
791
1040
|
};
|
|
792
1041
|
child.stdout.on('data', onData);
|
|
793
1042
|
child.stderr.on('data', onData);
|
|
794
1043
|
child.on('close', () => {
|
|
795
1044
|
state.watching.delete(sk);
|
|
796
|
-
if (state.mode ===
|
|
1045
|
+
if (state.mode === MODE.LIST)
|
|
797
1046
|
render(state);
|
|
798
1047
|
});
|
|
799
1048
|
render(state);
|
|
800
1049
|
}
|
|
801
1050
|
// --- Dependency-Aware Rebuild ---
|
|
802
|
-
function initDepGraphs(state) {
|
|
1051
|
+
export function initDepGraphs(state) {
|
|
803
1052
|
for (const group of state.groups) {
|
|
804
1053
|
if (group.error)
|
|
805
1054
|
continue;
|
|
806
1055
|
try {
|
|
807
|
-
state.depGraphs.set(group.file,
|
|
1056
|
+
state.depGraphs.set(group.file, parseDependencyGraph(group.file));
|
|
808
1057
|
}
|
|
809
1058
|
catch {
|
|
810
1059
|
// Ignore — no dep info for this file
|
|
@@ -858,19 +1107,19 @@ function topoSortDependents(graph, services, root) {
|
|
|
858
1107
|
}
|
|
859
1108
|
return sorted;
|
|
860
1109
|
}
|
|
861
|
-
function doCascadeRebuild(state) {
|
|
862
|
-
const entry =
|
|
1110
|
+
export function doCascadeRebuild(state) {
|
|
1111
|
+
const entry = selectedEntry(state);
|
|
863
1112
|
if (!entry)
|
|
864
1113
|
return;
|
|
865
|
-
const sk =
|
|
1114
|
+
const sk = statusKey(entry.file, entry.service);
|
|
866
1115
|
if (state.rebuilding.has(sk) || state.cascading.has(sk))
|
|
867
1116
|
return;
|
|
868
|
-
const effectiveFile = (
|
|
1117
|
+
const { file: effectiveFile, projectName } = getComposeTarget(state, entry.file, entry.service);
|
|
869
1118
|
let graph = state.depGraphs.get(effectiveFile);
|
|
870
1119
|
if (!graph) {
|
|
871
1120
|
// Try to parse dep graph for the effective file (may differ from original)
|
|
872
1121
|
try {
|
|
873
|
-
graph =
|
|
1122
|
+
graph = parseDependencyGraph(effectiveFile);
|
|
874
1123
|
state.depGraphs.set(effectiveFile, graph);
|
|
875
1124
|
}
|
|
876
1125
|
catch {
|
|
@@ -898,26 +1147,26 @@ function doCascadeRebuild(state) {
|
|
|
898
1147
|
state.cascading.set(sk, cascade);
|
|
899
1148
|
state.bottomLogLines.set(sk, { action: 'cascading', service: entry.service, lines: [] });
|
|
900
1149
|
state.showBottomLogs = true;
|
|
901
|
-
executeCascadeStep(state, effectiveFile, sk, cascade);
|
|
1150
|
+
executeCascadeStep(state, effectiveFile, sk, cascade, projectName);
|
|
902
1151
|
render(state);
|
|
903
1152
|
}
|
|
904
|
-
function executeCascadeStep(state, file, sk, cascade) {
|
|
1153
|
+
function executeCascadeStep(state, file, sk, cascade, projectName) {
|
|
905
1154
|
const step = cascade.steps[cascade.currentStepIdx];
|
|
906
1155
|
if (!step) {
|
|
907
1156
|
// All done
|
|
908
1157
|
state.cascading.delete(sk);
|
|
909
1158
|
pollStatuses(state);
|
|
910
|
-
if (state.mode ===
|
|
1159
|
+
if (state.mode === MODE.LIST)
|
|
911
1160
|
render(state);
|
|
912
1161
|
return;
|
|
913
1162
|
}
|
|
914
1163
|
step.status = 'in_progress';
|
|
915
1164
|
let child;
|
|
916
1165
|
if (step.action === 'rebuild') {
|
|
917
|
-
child =
|
|
1166
|
+
child = rebuildService(file, step.service, { noCache: state.noCache, noDeps: state.noDeps }, projectName);
|
|
918
1167
|
}
|
|
919
1168
|
else {
|
|
920
|
-
child =
|
|
1169
|
+
child = restartService(file, step.service, projectName);
|
|
921
1170
|
}
|
|
922
1171
|
cascade.child = child;
|
|
923
1172
|
let lineBuf = '';
|
|
@@ -932,12 +1181,12 @@ function executeCascadeStep(state, file, sk, cascade) {
|
|
|
932
1181
|
if (newLines.length === 0)
|
|
933
1182
|
return;
|
|
934
1183
|
info.lines.push(...newLines);
|
|
935
|
-
if (state.mode ===
|
|
1184
|
+
if (state.mode === MODE.LOGS && state.logBuildKey === sk) {
|
|
936
1185
|
state.logLines.push(...newLines);
|
|
937
1186
|
if (state.logAutoScroll)
|
|
938
1187
|
throttledRender(state);
|
|
939
1188
|
}
|
|
940
|
-
if (state.mode ===
|
|
1189
|
+
if (state.mode === MODE.LIST)
|
|
941
1190
|
throttledRender(state);
|
|
942
1191
|
};
|
|
943
1192
|
const childProcess = child;
|
|
@@ -948,7 +1197,7 @@ function executeCascadeStep(state, file, sk, cascade) {
|
|
|
948
1197
|
step.status = 'failed';
|
|
949
1198
|
state.cascading.delete(sk);
|
|
950
1199
|
pollStatuses(state);
|
|
951
|
-
if (state.mode ===
|
|
1200
|
+
if (state.mode === MODE.LIST)
|
|
952
1201
|
render(state);
|
|
953
1202
|
return;
|
|
954
1203
|
}
|
|
@@ -956,11 +1205,11 @@ function executeCascadeStep(state, file, sk, cascade) {
|
|
|
956
1205
|
cascade.currentStepIdx++;
|
|
957
1206
|
cascade.child = null;
|
|
958
1207
|
// Reset stats for rebuilt/restarted service
|
|
959
|
-
const stepSk =
|
|
1208
|
+
const stepSk = statusKey(file, step.service);
|
|
960
1209
|
state.containerStatsHistory.delete(stepSk);
|
|
961
1210
|
state.containerStats.delete(stepSk);
|
|
962
1211
|
if (cascade.currentStepIdx < cascade.steps.length) {
|
|
963
|
-
executeCascadeStep(state, file, sk, cascade);
|
|
1212
|
+
executeCascadeStep(state, file, sk, cascade, projectName);
|
|
964
1213
|
}
|
|
965
1214
|
else {
|
|
966
1215
|
state.cascading.delete(sk);
|
|
@@ -970,18 +1219,18 @@ function executeCascadeStep(state, file, sk, cascade) {
|
|
|
970
1219
|
info.action = 'started';
|
|
971
1220
|
info.lines = [];
|
|
972
1221
|
}
|
|
973
|
-
startBottomLogTail(state, sk, file, state.flatList[state.cursor]?.service || '');
|
|
1222
|
+
startBottomLogTail(state, sk, file, state.flatList[state.cursor]?.service || '', projectName);
|
|
974
1223
|
}
|
|
975
|
-
if (state.mode ===
|
|
1224
|
+
if (state.mode === MODE.LIST)
|
|
976
1225
|
render(state);
|
|
977
1226
|
});
|
|
978
1227
|
}
|
|
979
1228
|
// --- Exec ---
|
|
980
1229
|
function initExecState(state) {
|
|
981
|
-
const entry =
|
|
1230
|
+
const entry = selectedEntry(state);
|
|
982
1231
|
if (!entry)
|
|
983
1232
|
return false;
|
|
984
|
-
const sk =
|
|
1233
|
+
const sk = statusKey(entry.file, entry.service);
|
|
985
1234
|
const st = state.statuses.get(sk);
|
|
986
1235
|
if (!st || st.state !== 'running' || !st.id)
|
|
987
1236
|
return false;
|
|
@@ -994,29 +1243,29 @@ function initExecState(state) {
|
|
|
994
1243
|
state.execCwd = null;
|
|
995
1244
|
return true;
|
|
996
1245
|
}
|
|
997
|
-
function enterExecInline(state) {
|
|
1246
|
+
export function enterExecInline(state) {
|
|
998
1247
|
if (!initExecState(state))
|
|
999
1248
|
return;
|
|
1000
1249
|
state.execActive = true;
|
|
1001
1250
|
state.showBottomLogs = true;
|
|
1002
1251
|
render(state);
|
|
1003
1252
|
}
|
|
1004
|
-
function enterExec(state) {
|
|
1253
|
+
export function enterExec(state) {
|
|
1005
1254
|
if (!state.execActive) {
|
|
1006
1255
|
if (!initExecState(state))
|
|
1007
1256
|
return;
|
|
1008
1257
|
}
|
|
1009
1258
|
state.execActive = false;
|
|
1010
|
-
state.mode =
|
|
1259
|
+
state.mode = MODE.EXEC;
|
|
1011
1260
|
render(state);
|
|
1012
1261
|
}
|
|
1013
|
-
function exitExec(state) {
|
|
1262
|
+
export function exitExec(state) {
|
|
1014
1263
|
if (state.execChild) {
|
|
1015
1264
|
state.execChild.kill('SIGTERM');
|
|
1016
1265
|
state.execChild = null;
|
|
1017
1266
|
}
|
|
1018
|
-
const wasFullscreen = state.mode ===
|
|
1019
|
-
state.mode =
|
|
1267
|
+
const wasFullscreen = state.mode === MODE.EXEC;
|
|
1268
|
+
state.mode = MODE.LIST;
|
|
1020
1269
|
state.execActive = false;
|
|
1021
1270
|
state.execInput = '';
|
|
1022
1271
|
state.execOutputLines = [];
|
|
@@ -1034,10 +1283,10 @@ function isCdCommand(cmd) {
|
|
|
1034
1283
|
return null;
|
|
1035
1284
|
return match[2] ? match[2].trim() : '';
|
|
1036
1285
|
}
|
|
1037
|
-
function shellEscape(str) {
|
|
1286
|
+
export function shellEscape(str) {
|
|
1038
1287
|
return "'" + str.replace(/'/g, "'\\''") + "'";
|
|
1039
1288
|
}
|
|
1040
|
-
function runExecCommand(state) {
|
|
1289
|
+
export function runExecCommand(state) {
|
|
1041
1290
|
const cmd = state.execInput.trim();
|
|
1042
1291
|
if (!cmd || !state.execContainerId)
|
|
1043
1292
|
return;
|
|
@@ -1059,7 +1308,7 @@ function runExecCommand(state) {
|
|
|
1059
1308
|
const cdTarget = isCdCommand(cmd);
|
|
1060
1309
|
if (cdTarget !== null) {
|
|
1061
1310
|
const resolveCmd = cdTarget ? `cd ${shellEscape(cdTarget)} && pwd` : 'cd && pwd';
|
|
1062
|
-
const child =
|
|
1311
|
+
const child = execInContainer(state.execContainerId, resolveCmd, state.execCwd || undefined);
|
|
1063
1312
|
state.execChild = child;
|
|
1064
1313
|
let stdout = '';
|
|
1065
1314
|
let stderr = '';
|
|
@@ -1081,13 +1330,13 @@ function runExecCommand(state) {
|
|
|
1081
1330
|
state.execOutputLines.push(stripAnsi(line));
|
|
1082
1331
|
}
|
|
1083
1332
|
}
|
|
1084
|
-
if (state.mode ===
|
|
1333
|
+
if (state.mode === MODE.EXEC || state.execActive)
|
|
1085
1334
|
throttledRender(state);
|
|
1086
1335
|
});
|
|
1087
1336
|
render(state);
|
|
1088
1337
|
return;
|
|
1089
1338
|
}
|
|
1090
|
-
const child =
|
|
1339
|
+
const child = execInContainer(state.execContainerId, cmd, state.execCwd || undefined);
|
|
1091
1340
|
state.execChild = child;
|
|
1092
1341
|
let lineBuf = '';
|
|
1093
1342
|
const onData = (data) => {
|
|
@@ -1101,7 +1350,7 @@ function runExecCommand(state) {
|
|
|
1101
1350
|
if (state.execOutputLines.length > 200) {
|
|
1102
1351
|
state.execOutputLines = state.execOutputLines.slice(-200);
|
|
1103
1352
|
}
|
|
1104
|
-
if (state.mode ===
|
|
1353
|
+
if (state.mode === MODE.EXEC || state.execActive)
|
|
1105
1354
|
throttledRender(state);
|
|
1106
1355
|
};
|
|
1107
1356
|
child.stdout.on('data', onData);
|
|
@@ -1115,13 +1364,13 @@ function runExecCommand(state) {
|
|
|
1115
1364
|
state.execOutputLines.push(stripAnsi(lineBuf));
|
|
1116
1365
|
lineBuf = '';
|
|
1117
1366
|
}
|
|
1118
|
-
if (state.mode ===
|
|
1367
|
+
if (state.mode === MODE.EXEC || state.execActive)
|
|
1119
1368
|
throttledRender(state);
|
|
1120
1369
|
});
|
|
1121
1370
|
render(state);
|
|
1122
1371
|
}
|
|
1123
|
-
function enterLogs(state) {
|
|
1124
|
-
const entry =
|
|
1372
|
+
export function enterLogs(state) {
|
|
1373
|
+
const entry = selectedEntry(state);
|
|
1125
1374
|
if (!entry)
|
|
1126
1375
|
return;
|
|
1127
1376
|
if (moduleState.logFetchTimer) {
|
|
@@ -1131,11 +1380,11 @@ function enterLogs(state) {
|
|
|
1131
1380
|
// Carry over bottom panel search query to full log search
|
|
1132
1381
|
const carryQuery = state.bottomSearchQuery || '';
|
|
1133
1382
|
clearBottomSearch(state);
|
|
1134
|
-
const sk =
|
|
1383
|
+
const sk = statusKey(entry.file, entry.service);
|
|
1135
1384
|
const info = state.bottomLogLines.get(sk);
|
|
1136
1385
|
const isBuilding = state.rebuilding.has(sk) || state.cascading.has(sk);
|
|
1137
1386
|
const isBuildFailed = info && info.action === 'build_failed';
|
|
1138
|
-
state.mode =
|
|
1387
|
+
state.mode = MODE.LOGS;
|
|
1139
1388
|
state.logLines = [];
|
|
1140
1389
|
state.logScrollOffset = 0;
|
|
1141
1390
|
state.logAutoScroll = true;
|
|
@@ -1158,8 +1407,8 @@ function enterLogs(state) {
|
|
|
1158
1407
|
}
|
|
1159
1408
|
else {
|
|
1160
1409
|
state.logBuildKey = null;
|
|
1161
|
-
const effectiveFile = (
|
|
1162
|
-
const child =
|
|
1410
|
+
const { file: effectiveFile, projectName } = getComposeTarget(state, entry.file, entry.service);
|
|
1411
|
+
const child = tailLogs(effectiveFile, entry.service, 200, projectName);
|
|
1163
1412
|
state.logChild = child;
|
|
1164
1413
|
let lineBuf = '';
|
|
1165
1414
|
const onData = (data) => {
|
|
@@ -1190,7 +1439,7 @@ function enterLogs(state) {
|
|
|
1190
1439
|
}
|
|
1191
1440
|
render(state);
|
|
1192
1441
|
}
|
|
1193
|
-
function exitLogs(state) {
|
|
1442
|
+
export function exitLogs(state) {
|
|
1194
1443
|
if (state.logChild) {
|
|
1195
1444
|
state.logChild.kill('SIGTERM');
|
|
1196
1445
|
state.logChild = null;
|
|
@@ -1204,15 +1453,15 @@ function exitLogs(state) {
|
|
|
1204
1453
|
state.logHistoryLoaded = false;
|
|
1205
1454
|
state.logHistoryLoading = false;
|
|
1206
1455
|
state.logSearchPending = false;
|
|
1207
|
-
state.mode =
|
|
1456
|
+
state.mode = MODE.LIST;
|
|
1208
1457
|
pollStatuses(state);
|
|
1209
1458
|
render(state);
|
|
1210
1459
|
}
|
|
1211
1460
|
// --- Log History Loading ---
|
|
1212
|
-
function loadMoreLogHistory(state) {
|
|
1461
|
+
export function loadMoreLogHistory(state) {
|
|
1213
1462
|
if (state.logHistoryLoaded || state.logHistoryLoading)
|
|
1214
1463
|
return;
|
|
1215
|
-
const entry =
|
|
1464
|
+
const entry = selectedEntry(state);
|
|
1216
1465
|
if (!entry)
|
|
1217
1466
|
return;
|
|
1218
1467
|
// Escalate: 200 → 1000 → 5000 → all
|
|
@@ -1225,8 +1474,8 @@ function loadMoreLogHistory(state) {
|
|
|
1225
1474
|
nextTail = 'all';
|
|
1226
1475
|
state.logHistoryLoading = true;
|
|
1227
1476
|
const snapshotLen = state.logLines.length;
|
|
1228
|
-
const effectiveFile = (
|
|
1229
|
-
const child =
|
|
1477
|
+
const { file: effectiveFile, projectName } = getComposeTarget(state, entry.file, entry.service);
|
|
1478
|
+
const child = fetchServiceLogs(effectiveFile, entry.service, nextTail, projectName);
|
|
1230
1479
|
state.logHistoryChild = child;
|
|
1231
1480
|
let output = '';
|
|
1232
1481
|
child.stdout.on('data', (d) => { output += d.toString(); });
|
|
@@ -1263,7 +1512,7 @@ function loadMoreLogHistory(state) {
|
|
|
1263
1512
|
});
|
|
1264
1513
|
}
|
|
1265
1514
|
// --- Log Search ---
|
|
1266
|
-
function executeLogSearch(state) {
|
|
1515
|
+
export function executeLogSearch(state) {
|
|
1267
1516
|
const query = state.logSearchQuery;
|
|
1268
1517
|
state.logSearchMatches = [];
|
|
1269
1518
|
state.logSearchMatchIdx = -1;
|
|
@@ -1290,24 +1539,24 @@ function scrollToLogLine(state, lineIdx) {
|
|
|
1290
1539
|
state.logAutoScroll = state.logScrollOffset === 0;
|
|
1291
1540
|
render(state);
|
|
1292
1541
|
}
|
|
1293
|
-
function jumpToNextMatch(state) {
|
|
1542
|
+
export function jumpToNextMatch(state) {
|
|
1294
1543
|
if (state.logSearchMatches.length === 0)
|
|
1295
1544
|
return;
|
|
1296
1545
|
state.logSearchMatchIdx = (state.logSearchMatchIdx + 1) % state.logSearchMatches.length;
|
|
1297
1546
|
scrollToLogLine(state, state.logSearchMatches[state.logSearchMatchIdx]);
|
|
1298
1547
|
}
|
|
1299
|
-
function jumpToPrevMatch(state) {
|
|
1548
|
+
export function jumpToPrevMatch(state) {
|
|
1300
1549
|
if (state.logSearchMatches.length === 0)
|
|
1301
1550
|
return;
|
|
1302
1551
|
state.logSearchMatchIdx = (state.logSearchMatchIdx - 1 + state.logSearchMatches.length) % state.logSearchMatches.length;
|
|
1303
1552
|
scrollToLogLine(state, state.logSearchMatches[state.logSearchMatchIdx]);
|
|
1304
1553
|
}
|
|
1305
1554
|
// --- Bottom Panel Search ---
|
|
1306
|
-
function executeBottomSearch(state) {
|
|
1307
|
-
const entry =
|
|
1555
|
+
export function executeBottomSearch(state) {
|
|
1556
|
+
const entry = selectedEntry(state);
|
|
1308
1557
|
if (!entry || !state.bottomSearchQuery)
|
|
1309
1558
|
return;
|
|
1310
|
-
const sk =
|
|
1559
|
+
const sk = statusKey(entry.file, entry.service);
|
|
1311
1560
|
const info = state.bottomLogLines.get(sk);
|
|
1312
1561
|
if (!info)
|
|
1313
1562
|
return;
|
|
@@ -1323,8 +1572,8 @@ function executeBottomSearch(state) {
|
|
|
1323
1572
|
state.bottomSearchLoading = true;
|
|
1324
1573
|
state.bottomSearchTotalMatches = 0;
|
|
1325
1574
|
render(state);
|
|
1326
|
-
const effectiveFile = (
|
|
1327
|
-
const child =
|
|
1575
|
+
const { file: effectiveFile, projectName } = getComposeTarget(state, entry.file, entry.service);
|
|
1576
|
+
const child = fetchServiceLogs(effectiveFile, entry.service, 'all', projectName);
|
|
1328
1577
|
state.bottomSearchChild = child;
|
|
1329
1578
|
let output = '';
|
|
1330
1579
|
child.stdout.on('data', (d) => { output += d.toString(); });
|
|
@@ -1355,11 +1604,11 @@ function executeBottomSearch(state) {
|
|
|
1355
1604
|
if (currentInfo) {
|
|
1356
1605
|
currentInfo.lines = matchingLines.slice(-maxLines);
|
|
1357
1606
|
}
|
|
1358
|
-
if (state.mode ===
|
|
1607
|
+
if (state.mode === MODE.LIST)
|
|
1359
1608
|
render(state);
|
|
1360
1609
|
});
|
|
1361
1610
|
}
|
|
1362
|
-
function clearBottomSearch(state) {
|
|
1611
|
+
export function clearBottomSearch(state) {
|
|
1363
1612
|
if (state.bottomSearchChild) {
|
|
1364
1613
|
state.bottomSearchChild.kill('SIGTERM');
|
|
1365
1614
|
state.bottomSearchChild = null;
|
|
@@ -1376,12 +1625,12 @@ function clearBottomSearch(state) {
|
|
|
1376
1625
|
}
|
|
1377
1626
|
}
|
|
1378
1627
|
// --- Input Handling ---
|
|
1379
|
-
function handleKeypress(state, key) {
|
|
1380
|
-
if (key === '\x03' && state.mode !==
|
|
1628
|
+
export function handleKeypress(state, key) {
|
|
1629
|
+
if (key === '\x03' && state.mode !== MODE.EXEC && !state.execActive) {
|
|
1381
1630
|
cleanup(state);
|
|
1382
1631
|
process.exit(0);
|
|
1383
1632
|
}
|
|
1384
|
-
if (state.mode ===
|
|
1633
|
+
if (state.mode === MODE.EXEC) {
|
|
1385
1634
|
if (key === '\x1b') {
|
|
1386
1635
|
exitExec(state);
|
|
1387
1636
|
}
|
|
@@ -1419,6 +1668,11 @@ function handleKeypress(state, key) {
|
|
|
1419
1668
|
render(state);
|
|
1420
1669
|
}
|
|
1421
1670
|
}
|
|
1671
|
+
else if (key === '\x11') {
|
|
1672
|
+
// Ctrl+Q — quit
|
|
1673
|
+
cleanup(state);
|
|
1674
|
+
process.exit(0);
|
|
1675
|
+
}
|
|
1422
1676
|
else if (key === '\x03') {
|
|
1423
1677
|
// Ctrl+C — kill current exec child
|
|
1424
1678
|
if (state.execChild) {
|
|
@@ -1438,7 +1692,7 @@ function handleKeypress(state, key) {
|
|
|
1438
1692
|
}
|
|
1439
1693
|
return;
|
|
1440
1694
|
}
|
|
1441
|
-
if (state.mode ===
|
|
1695
|
+
if (state.mode === MODE.LOGS) {
|
|
1442
1696
|
if (state.logSearchActive) {
|
|
1443
1697
|
if (key === '\x1b') {
|
|
1444
1698
|
state.logSearchActive = false;
|
|
@@ -1563,8 +1817,14 @@ function handleKeypress(state, key) {
|
|
|
1563
1817
|
}
|
|
1564
1818
|
else if (key === '\r') {
|
|
1565
1819
|
const target = state.worktreePickerEntries[state.worktreePickerCursor];
|
|
1566
|
-
if (target)
|
|
1567
|
-
|
|
1820
|
+
if (target) {
|
|
1821
|
+
if (state.multiSelected.size > 0) {
|
|
1822
|
+
doWorktreeSwitchMulti(state, target);
|
|
1823
|
+
}
|
|
1824
|
+
else {
|
|
1825
|
+
doWorktreeSwitch(state, target);
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1568
1828
|
}
|
|
1569
1829
|
else if (key === 'j' || key === '\x1b[B') {
|
|
1570
1830
|
state.worktreePickerCursor = Math.min(state.worktreePickerEntries.length - 1, state.worktreePickerCursor + 1);
|
|
@@ -1617,6 +1877,11 @@ function handleKeypress(state, key) {
|
|
|
1617
1877
|
render(state);
|
|
1618
1878
|
}
|
|
1619
1879
|
}
|
|
1880
|
+
else if (key === '\x11') {
|
|
1881
|
+
// Ctrl+Q — quit
|
|
1882
|
+
cleanup(state);
|
|
1883
|
+
process.exit(0);
|
|
1884
|
+
}
|
|
1620
1885
|
else if (key === '\x03') {
|
|
1621
1886
|
if (state.execChild) {
|
|
1622
1887
|
state.execChild.kill('SIGTERM');
|
|
@@ -1629,7 +1894,8 @@ function handleKeypress(state, key) {
|
|
|
1629
1894
|
process.exit(0);
|
|
1630
1895
|
}
|
|
1631
1896
|
}
|
|
1632
|
-
else if (key === '
|
|
1897
|
+
else if (key === '\x06') {
|
|
1898
|
+
// Ctrl+F — expand to full screen
|
|
1633
1899
|
enterExec(state);
|
|
1634
1900
|
}
|
|
1635
1901
|
else if (key.length === 1 && key >= ' ') {
|
|
@@ -1663,51 +1929,157 @@ function handleKeypress(state, key) {
|
|
|
1663
1929
|
}
|
|
1664
1930
|
return;
|
|
1665
1931
|
}
|
|
1932
|
+
// LIST mode - multiselect Esc
|
|
1933
|
+
if (key === '\x1b' && state.multiSelected.size > 0) {
|
|
1934
|
+
state.multiSelected.clear();
|
|
1935
|
+
updateSelectedLogs(state);
|
|
1936
|
+
render(state);
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1939
|
+
const isMulti = state.multiSelected.size > 0;
|
|
1666
1940
|
// LIST mode
|
|
1667
1941
|
switch (key) {
|
|
1668
1942
|
case 'j':
|
|
1669
1943
|
case '\x1b[B':
|
|
1670
|
-
|
|
1671
|
-
|
|
1944
|
+
moveCursor(state, 1);
|
|
1945
|
+
if (!isMulti)
|
|
1946
|
+
updateSelectedLogs(state);
|
|
1672
1947
|
render(state);
|
|
1673
1948
|
break;
|
|
1674
1949
|
case 'k':
|
|
1675
1950
|
case '\x1b[A':
|
|
1676
|
-
|
|
1677
|
-
|
|
1951
|
+
moveCursor(state, -1);
|
|
1952
|
+
if (!isMulti)
|
|
1953
|
+
updateSelectedLogs(state);
|
|
1678
1954
|
render(state);
|
|
1679
1955
|
break;
|
|
1680
|
-
case '
|
|
1681
|
-
|
|
1956
|
+
case 'v': {
|
|
1957
|
+
const vEntry = selectedEntry(state);
|
|
1958
|
+
if (vEntry) {
|
|
1959
|
+
const vSk = statusKey(vEntry.file, vEntry.service);
|
|
1960
|
+
if (state.multiSelected.has(vSk)) {
|
|
1961
|
+
state.multiSelected.delete(vSk);
|
|
1962
|
+
}
|
|
1963
|
+
else {
|
|
1964
|
+
state.multiSelected.add(vSk);
|
|
1965
|
+
}
|
|
1966
|
+
moveCursor(state, 1);
|
|
1967
|
+
render(state);
|
|
1968
|
+
}
|
|
1969
|
+
break;
|
|
1970
|
+
}
|
|
1971
|
+
case 'b': {
|
|
1972
|
+
if (isMulti) {
|
|
1973
|
+
for (const mSk of state.multiSelected) {
|
|
1974
|
+
const mEntry = state.flatList.find(e => statusKey(e.file, e.service) === mSk);
|
|
1975
|
+
if (mEntry && !state.rebuilding.has(mSk)) {
|
|
1976
|
+
doRebuildEntry(state, mEntry);
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
render(state);
|
|
1980
|
+
}
|
|
1981
|
+
else {
|
|
1982
|
+
const bEntry = selectedEntry(state);
|
|
1983
|
+
if (bEntry) {
|
|
1984
|
+
const bSk = statusKey(bEntry.file, bEntry.service);
|
|
1985
|
+
if (state.rebuilding.has(bSk)) {
|
|
1986
|
+
state.rebuilding.get(bSk).kill('SIGTERM');
|
|
1987
|
+
state.rebuilding.delete(bSk);
|
|
1988
|
+
const bInfo = state.bottomLogLines.get(bSk);
|
|
1989
|
+
if (bInfo)
|
|
1990
|
+
bInfo.action = 'build_failed';
|
|
1991
|
+
pollStatuses(state);
|
|
1992
|
+
render(state);
|
|
1993
|
+
}
|
|
1994
|
+
else {
|
|
1995
|
+
doRebuild(state);
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1682
1999
|
break;
|
|
2000
|
+
}
|
|
1683
2001
|
case 'd':
|
|
1684
|
-
|
|
2002
|
+
if (!isMulti)
|
|
2003
|
+
doCascadeRebuild(state);
|
|
1685
2004
|
break;
|
|
1686
2005
|
case 'w':
|
|
1687
|
-
|
|
2006
|
+
if (!isMulti)
|
|
2007
|
+
doWatch(state);
|
|
1688
2008
|
break;
|
|
1689
2009
|
case 'e':
|
|
1690
|
-
|
|
2010
|
+
if (!isMulti)
|
|
2011
|
+
enterExecInline(state);
|
|
1691
2012
|
break;
|
|
1692
2013
|
case 'x':
|
|
1693
|
-
|
|
2014
|
+
if (!isMulti)
|
|
2015
|
+
enterExec(state);
|
|
1694
2016
|
break;
|
|
1695
2017
|
case 's': {
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
2018
|
+
if (isMulti) {
|
|
2019
|
+
for (const mSk of state.multiSelected) {
|
|
2020
|
+
const mEntry = state.flatList.find(e => statusKey(e.file, e.service) === mSk);
|
|
2021
|
+
if (!mEntry)
|
|
2022
|
+
continue;
|
|
2023
|
+
const mSt = state.statuses.get(mSk);
|
|
2024
|
+
if (state.restarting.has(mSk) || state.starting.has(mSk))
|
|
2025
|
+
continue;
|
|
2026
|
+
if (mSt && mSt.state === 'running') {
|
|
2027
|
+
doRestartEntry(state, mEntry);
|
|
2028
|
+
}
|
|
2029
|
+
else {
|
|
2030
|
+
doStartEntry(state, mEntry);
|
|
2031
|
+
}
|
|
1702
2032
|
}
|
|
1703
|
-
|
|
1704
|
-
|
|
2033
|
+
render(state);
|
|
2034
|
+
}
|
|
2035
|
+
else {
|
|
2036
|
+
const sEntry = selectedEntry(state);
|
|
2037
|
+
if (sEntry) {
|
|
2038
|
+
const sSk = statusKey(sEntry.file, sEntry.service);
|
|
2039
|
+
if (state.restarting.has(sSk)) {
|
|
2040
|
+
state.restarting.get(sSk).kill('SIGTERM');
|
|
2041
|
+
state.restarting.delete(sSk);
|
|
2042
|
+
const sInfo = state.bottomLogLines.get(sSk);
|
|
2043
|
+
if (sInfo)
|
|
2044
|
+
sInfo.action = 'restart_failed';
|
|
2045
|
+
pollStatuses(state);
|
|
2046
|
+
render(state);
|
|
2047
|
+
}
|
|
2048
|
+
else if (state.starting.has(sSk)) {
|
|
2049
|
+
state.starting.get(sSk).kill('SIGTERM');
|
|
2050
|
+
state.starting.delete(sSk);
|
|
2051
|
+
const sInfo = state.bottomLogLines.get(sSk);
|
|
2052
|
+
if (sInfo)
|
|
2053
|
+
sInfo.action = 'start_failed';
|
|
2054
|
+
pollStatuses(state);
|
|
2055
|
+
render(state);
|
|
2056
|
+
}
|
|
2057
|
+
else {
|
|
2058
|
+
const sSt = state.statuses.get(sSk);
|
|
2059
|
+
if (sSt && sSt.state === 'running') {
|
|
2060
|
+
doRestart(state);
|
|
2061
|
+
}
|
|
2062
|
+
else {
|
|
2063
|
+
doStart(state);
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
1705
2066
|
}
|
|
1706
2067
|
}
|
|
1707
2068
|
break;
|
|
1708
2069
|
}
|
|
1709
2070
|
case 'p':
|
|
1710
|
-
|
|
2071
|
+
if (isMulti) {
|
|
2072
|
+
for (const mSk of state.multiSelected) {
|
|
2073
|
+
const mEntry = state.flatList.find(e => statusKey(e.file, e.service) === mSk);
|
|
2074
|
+
if (mEntry && !state.stopping.has(mSk)) {
|
|
2075
|
+
doStopEntry(state, mEntry);
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
render(state);
|
|
2079
|
+
}
|
|
2080
|
+
else {
|
|
2081
|
+
doStop(state);
|
|
2082
|
+
}
|
|
1711
2083
|
break;
|
|
1712
2084
|
case 'n':
|
|
1713
2085
|
state.noCache = !state.noCache;
|
|
@@ -1719,14 +2091,22 @@ function handleKeypress(state, key) {
|
|
|
1719
2091
|
break;
|
|
1720
2092
|
case 'f':
|
|
1721
2093
|
case '\r':
|
|
1722
|
-
|
|
2094
|
+
if (!isMulti)
|
|
2095
|
+
enterLogs(state);
|
|
1723
2096
|
break;
|
|
1724
2097
|
case 'l':
|
|
1725
|
-
|
|
1726
|
-
|
|
2098
|
+
if (!isMulti) {
|
|
2099
|
+
state.showBottomLogs = !state.showBottomLogs;
|
|
2100
|
+
render(state);
|
|
2101
|
+
}
|
|
1727
2102
|
break;
|
|
1728
2103
|
case 't':
|
|
1729
|
-
|
|
2104
|
+
if (isMulti) {
|
|
2105
|
+
openWorktreePickerMulti(state);
|
|
2106
|
+
}
|
|
2107
|
+
else {
|
|
2108
|
+
openWorktreePicker(state);
|
|
2109
|
+
}
|
|
1730
2110
|
break;
|
|
1731
2111
|
case 'q':
|
|
1732
2112
|
cleanup(state);
|
|
@@ -1734,22 +2114,27 @@ function handleKeypress(state, key) {
|
|
|
1734
2114
|
break;
|
|
1735
2115
|
case 'G':
|
|
1736
2116
|
state.cursor = state.flatList.length - 1;
|
|
1737
|
-
|
|
2117
|
+
if (!isMulti)
|
|
2118
|
+
updateSelectedLogs(state);
|
|
1738
2119
|
render(state);
|
|
1739
2120
|
break;
|
|
1740
2121
|
case 'g':
|
|
1741
2122
|
break;
|
|
1742
2123
|
case '/':
|
|
1743
|
-
if (state.showBottomLogs) {
|
|
2124
|
+
if (!isMulti && state.showBottomLogs) {
|
|
1744
2125
|
state.bottomSearchActive = true;
|
|
1745
2126
|
state.bottomSearchQuery = '';
|
|
1746
2127
|
render(state);
|
|
1747
2128
|
}
|
|
1748
2129
|
break;
|
|
2130
|
+
default:
|
|
2131
|
+
if (!isMulti)
|
|
2132
|
+
executeCustomAction(state, key);
|
|
2133
|
+
break;
|
|
1749
2134
|
}
|
|
1750
2135
|
}
|
|
1751
2136
|
// --- Arrow key sequence buffering ---
|
|
1752
|
-
function createInputHandler(state) {
|
|
2137
|
+
export function createInputHandler(state) {
|
|
1753
2138
|
let buf = '';
|
|
1754
2139
|
let gPending = false;
|
|
1755
2140
|
return function onData(data) {
|
|
@@ -1781,19 +2166,19 @@ function createInputHandler(state) {
|
|
|
1781
2166
|
}
|
|
1782
2167
|
const ch = buf[0];
|
|
1783
2168
|
buf = buf.slice(1);
|
|
1784
|
-
if (state.logSearchActive || state.bottomSearchActive || state.worktreePickerActive || state.mode ===
|
|
2169
|
+
if (state.logSearchActive || state.bottomSearchActive || state.worktreePickerActive || state.mode === MODE.EXEC || state.execActive) {
|
|
1785
2170
|
handleKeypress(state, ch);
|
|
1786
2171
|
continue;
|
|
1787
2172
|
}
|
|
1788
2173
|
if (ch === 'g') {
|
|
1789
2174
|
if (gPending) {
|
|
1790
2175
|
gPending = false;
|
|
1791
|
-
if (state.mode ===
|
|
2176
|
+
if (state.mode === MODE.LIST) {
|
|
1792
2177
|
state.cursor = 0;
|
|
1793
2178
|
state.scrollOffset = 0;
|
|
1794
2179
|
updateSelectedLogs(state);
|
|
1795
2180
|
}
|
|
1796
|
-
else if (state.mode ===
|
|
2181
|
+
else if (state.mode === MODE.LOGS) {
|
|
1797
2182
|
state.logAutoScroll = false;
|
|
1798
2183
|
const ggRows = process.stdout.rows ?? 24;
|
|
1799
2184
|
const ggAvailable = Math.max(1, ggRows - 9);
|
|
@@ -1821,7 +2206,7 @@ function createInputHandler(state) {
|
|
|
1821
2206
|
};
|
|
1822
2207
|
}
|
|
1823
2208
|
// --- Cleanup ---
|
|
1824
|
-
function cleanup(state) {
|
|
2209
|
+
export function cleanup(state) {
|
|
1825
2210
|
if (state.logChild) {
|
|
1826
2211
|
state.logChild.kill('SIGTERM');
|
|
1827
2212
|
state.logChild = null;
|
|
@@ -1872,6 +2257,11 @@ function cleanup(state) {
|
|
|
1872
2257
|
clearTimeout(moduleState.pendingRender);
|
|
1873
2258
|
moduleState.pendingRender = null;
|
|
1874
2259
|
}
|
|
2260
|
+
stopBottomLogLoadingAnim(state);
|
|
2261
|
+
if (state.animTimer) {
|
|
2262
|
+
clearInterval(state.animTimer);
|
|
2263
|
+
state.animTimer = null;
|
|
2264
|
+
}
|
|
1875
2265
|
if (state.logScanTimer) {
|
|
1876
2266
|
clearInterval(state.logScanTimer);
|
|
1877
2267
|
}
|
|
@@ -1881,13 +2271,19 @@ function cleanup(state) {
|
|
|
1881
2271
|
if (state.statsTimer) {
|
|
1882
2272
|
clearInterval(state.statsTimer);
|
|
1883
2273
|
}
|
|
1884
|
-
|
|
2274
|
+
// Only write raw ANSI cleanup if Ink is not managing the terminal
|
|
2275
|
+
if (!state._inkRender) {
|
|
2276
|
+
process.stdout.write('\x1b[r' + showCursor() + '\x1b[0m\x1b[?1049l');
|
|
2277
|
+
}
|
|
2278
|
+
else {
|
|
2279
|
+
process.stdout.write('\x1b[?1049l');
|
|
2280
|
+
}
|
|
1885
2281
|
}
|
|
1886
2282
|
// Expose for testing
|
|
1887
|
-
function _getModuleState() {
|
|
2283
|
+
export function _getModuleState() {
|
|
1888
2284
|
return moduleState;
|
|
1889
2285
|
}
|
|
1890
|
-
function _setModuleState(ms) {
|
|
2286
|
+
export function _setModuleState(ms) {
|
|
1891
2287
|
moduleState = ms;
|
|
1892
2288
|
}
|
|
1893
2289
|
// --- Main ---
|
|
@@ -1895,46 +2291,70 @@ async function main() {
|
|
|
1895
2291
|
// Enter alternate screen buffer so pre-launch output (e.g. npx install) is hidden
|
|
1896
2292
|
process.stdout.write('\x1b[?1049h');
|
|
1897
2293
|
const config = loadConfig();
|
|
1898
|
-
const state =
|
|
2294
|
+
const state = createState(config);
|
|
1899
2295
|
state.groups = discoverServices(config);
|
|
1900
|
-
state.flatList =
|
|
2296
|
+
state.flatList = buildFlatList(state.groups);
|
|
1901
2297
|
if (state.flatList.length === 0) {
|
|
1902
2298
|
process.stderr.write('No services found in any compose file.\n');
|
|
1903
2299
|
process.exit(1);
|
|
1904
2300
|
}
|
|
1905
2301
|
pollStatuses(state);
|
|
1906
2302
|
initDepGraphs(state);
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
const
|
|
1913
|
-
|
|
1914
|
-
|
|
2303
|
+
const themeMode = await detectTheme(config.theme);
|
|
2304
|
+
setActivePalette(getPalette(themeMode));
|
|
2305
|
+
// Load Ink and React
|
|
2306
|
+
const inkModule = await import('ink');
|
|
2307
|
+
React = await import('react');
|
|
2308
|
+
const appModule = await import('./components/App.js');
|
|
2309
|
+
AppComponent = appModule.App;
|
|
2310
|
+
inkRenderFn = inkModule.render;
|
|
2311
|
+
// Handle keypresses through the imperative handleKeypress + gg logic
|
|
2312
|
+
const onKeypress = (key) => {
|
|
2313
|
+
if (key === 'gg') {
|
|
2314
|
+
if (state.mode === MODE.LIST) {
|
|
2315
|
+
state.cursor = 0;
|
|
2316
|
+
state.scrollOffset = 0;
|
|
2317
|
+
updateSelectedLogs(state);
|
|
2318
|
+
}
|
|
2319
|
+
else if (state.mode === MODE.LOGS) {
|
|
2320
|
+
state.logAutoScroll = false;
|
|
2321
|
+
const ggRows = process.stdout.rows ?? 24;
|
|
2322
|
+
const ggAvailable = Math.max(1, ggRows - 9);
|
|
2323
|
+
state.logScrollOffset = Math.max(0, state.logLines.length - ggAvailable);
|
|
2324
|
+
if (!state.logHistoryLoaded) {
|
|
2325
|
+
state.logFetchedTailCount = 5000;
|
|
2326
|
+
loadMoreLogHistory(state);
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
render(state);
|
|
2330
|
+
return;
|
|
2331
|
+
}
|
|
2332
|
+
handleKeypress(state, key);
|
|
2333
|
+
};
|
|
2334
|
+
// Render with Ink
|
|
2335
|
+
const inkInstance = inkRenderFn(React.createElement(AppComponent, { state, themeMode, onKeypress }), {
|
|
2336
|
+
exitOnCtrlC: false,
|
|
2337
|
+
patchConsole: false,
|
|
2338
|
+
});
|
|
1915
2339
|
pollLogCounts(state);
|
|
1916
2340
|
updateSelectedLogs(state);
|
|
1917
|
-
|
|
2341
|
+
ensureAnimTimer(state);
|
|
1918
2342
|
state.pollTimer = setInterval(() => {
|
|
1919
|
-
if (state.mode ===
|
|
2343
|
+
if (state.mode === MODE.LIST) {
|
|
1920
2344
|
pollStatuses(state);
|
|
1921
|
-
render(state);
|
|
1922
2345
|
}
|
|
1923
2346
|
}, config.pollInterval);
|
|
1924
2347
|
state.logScanTimer = setInterval(() => {
|
|
1925
|
-
if (state.mode ===
|
|
2348
|
+
if (state.mode === MODE.LIST) {
|
|
1926
2349
|
pollLogCounts(state);
|
|
1927
2350
|
}
|
|
1928
2351
|
}, config.logScanInterval || 10000);
|
|
1929
2352
|
pollContainerStats(state);
|
|
1930
2353
|
state.statsTimer = setInterval(() => {
|
|
1931
|
-
if (state.mode ===
|
|
2354
|
+
if (state.mode === MODE.LIST) {
|
|
1932
2355
|
pollContainerStats(state);
|
|
1933
2356
|
}
|
|
1934
2357
|
}, config.statsInterval || 5000);
|
|
1935
|
-
process.stdout.on('resize', () => {
|
|
1936
|
-
render(state);
|
|
1937
|
-
});
|
|
1938
2358
|
process.on('exit', () => cleanup(state));
|
|
1939
2359
|
process.on('SIGINT', () => {
|
|
1940
2360
|
cleanup(state);
|
|
@@ -1946,7 +2366,9 @@ async function main() {
|
|
|
1946
2366
|
});
|
|
1947
2367
|
}
|
|
1948
2368
|
// Only run main when executed directly (not when imported for testing)
|
|
1949
|
-
|
|
2369
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
2370
|
+
const __argv1 = process.argv[1] ? fs.realpathSync(path.resolve(process.argv[1])) : '';
|
|
2371
|
+
if (__argv1 === __filename) {
|
|
1950
2372
|
main().catch((err) => {
|
|
1951
2373
|
process.stderr.write(`${err}\n`);
|
|
1952
2374
|
process.exit(1);
|