recomposable 1.1.0 → 1.1.2

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/lib/docker.js DELETED
@@ -1,231 +0,0 @@
1
- 'use strict';
2
-
3
- const { execFileSync, spawn } = require('child_process');
4
- const path = require('path');
5
-
6
- function listServices(file) {
7
- const cwd = path.dirname(path.resolve(file));
8
- const args = ['compose', '-f', path.resolve(file), 'config', '--services'];
9
- const out = execFileSync('docker', args, { cwd, encoding: 'utf8', timeout: 10000 });
10
- return out.trim().split('\n').filter(Boolean);
11
- }
12
-
13
- function getStatuses(file) {
14
- const cwd = path.dirname(path.resolve(file));
15
- const args = ['compose', '-f', path.resolve(file), 'ps', '--format', 'json'];
16
- let out;
17
- try {
18
- out = execFileSync('docker', args, { cwd, encoding: 'utf8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
19
- } catch {
20
- return new Map();
21
- }
22
-
23
- const trimmed = out.trim();
24
- if (!trimmed) return new Map();
25
-
26
- const statuses = new Map();
27
- let containers;
28
-
29
- // docker compose ps outputs NDJSON (one object per line) or a JSON array
30
- try {
31
- if (trimmed.startsWith('[')) {
32
- containers = JSON.parse(trimmed);
33
- } else {
34
- containers = trimmed.split('\n').filter(Boolean).map(line => JSON.parse(line));
35
- }
36
- } catch {
37
- return new Map();
38
- }
39
-
40
- const idToService = new Map();
41
-
42
- for (const c of containers) {
43
- const name = c.Service || c.Name;
44
- const state = (c.State || '').toLowerCase();
45
- const health = (c.Health || '').toLowerCase();
46
- const createdAt = c.CreatedAt || null;
47
- const id = c.ID || null;
48
-
49
- // Extract published ports
50
- let ports = [];
51
- if (Array.isArray(c.Publishers)) {
52
- for (const p of c.Publishers) {
53
- if (p.PublishedPort && p.PublishedPort > 0) {
54
- ports.push({ published: p.PublishedPort, target: p.TargetPort });
55
- }
56
- }
57
- } else if (c.Ports) {
58
- // Fallback: parse from Ports string like "0.0.0.0:3000->3000/tcp"
59
- const portMatches = c.Ports.matchAll(/(\d+)->(\d+)/g);
60
- for (const m of portMatches) {
61
- ports.push({ published: parseInt(m[1], 10), target: parseInt(m[2], 10) });
62
- }
63
- }
64
- // Deduplicate by published port
65
- const seen = new Set();
66
- ports = ports.filter(p => {
67
- if (seen.has(p.published)) return false;
68
- seen.add(p.published);
69
- return true;
70
- });
71
-
72
- statuses.set(name, { state, health, createdAt, startedAt: null, id: id || null, ports });
73
- if (id) idToService.set(id, name);
74
- }
75
-
76
- // Batch docker inspect to get startedAt timestamps
77
- const ids = [...idToService.keys()];
78
- if (ids.length > 0) {
79
- try {
80
- const inspectOut = execFileSync('docker', ['inspect', ...ids], {
81
- encoding: 'utf8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe']
82
- });
83
- const inspected = JSON.parse(inspectOut);
84
- for (const info of inspected) {
85
- for (const [id, svc] of idToService) {
86
- if (info.Id && info.Id.startsWith(id)) {
87
- const status = statuses.get(svc);
88
- if (status && info.State) {
89
- status.startedAt = info.State.StartedAt || null;
90
- }
91
- break;
92
- }
93
- }
94
- }
95
- } catch {
96
- // Ignore inspect errors
97
- }
98
- }
99
-
100
- return statuses;
101
- }
102
-
103
- function rebuildService(file, service, opts = {}) {
104
- const cwd = path.dirname(path.resolve(file));
105
- const resolvedFile = path.resolve(file);
106
- const spawnOpts = {
107
- cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: false,
108
- env: { ...process.env, BUILDKIT_PROGRESS: 'plain' },
109
- };
110
-
111
- if (opts.noCache) {
112
- // Chain: build --no-cache then up --force-recreate (skip all caches)
113
- // Use safe spawn (no shell) to avoid command injection
114
- const { EventEmitter } = require('events');
115
- const { PassThrough } = require('stream');
116
- const emitter = new EventEmitter();
117
- const stdout = new PassThrough();
118
- const stderr = new PassThrough();
119
- emitter.stdout = stdout;
120
- emitter.stderr = stderr;
121
-
122
- const buildChild = spawn('docker', ['compose', '-f', resolvedFile, 'build', '--no-cache', service], spawnOpts);
123
- buildChild.stdout.pipe(stdout, { end: false });
124
- buildChild.stderr.pipe(stderr, { end: false });
125
-
126
- buildChild.on('close', (code) => {
127
- if (code !== 0) {
128
- stdout.end();
129
- stderr.end();
130
- emitter.emit('close', code);
131
- return;
132
- }
133
- const upChild = spawn('docker', ['compose', '-f', resolvedFile, 'up', '-d', '--force-recreate', service], spawnOpts);
134
- upChild.stdout.pipe(stdout);
135
- upChild.stderr.pipe(stderr);
136
- upChild.on('close', (upCode) => emitter.emit('close', upCode));
137
- emitter.kill = (sig) => upChild.kill(sig);
138
- });
139
-
140
- emitter.kill = (sig) => buildChild.kill(sig);
141
- return emitter;
142
- }
143
-
144
- const args = ['compose', '-f', resolvedFile, 'up', '-d', '--build', service];
145
- const child = spawn('docker', args, spawnOpts);
146
- return child;
147
- }
148
-
149
- function tailLogs(file, service, tailLines) {
150
- const cwd = path.dirname(path.resolve(file));
151
- const args = ['compose', '-f', path.resolve(file), 'logs', '-f', '--tail', String(tailLines), service];
152
- const child = spawn('docker', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
153
- return child;
154
- }
155
-
156
- function getContainerId(file, service) {
157
- const cwd = path.dirname(path.resolve(file));
158
- const args = ['compose', '-f', path.resolve(file), 'ps', '-q', service];
159
- try {
160
- const out = execFileSync('docker', args, { cwd, encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] });
161
- return out.trim() || null;
162
- } catch {
163
- return null;
164
- }
165
- }
166
-
167
- function tailContainerLogs(containerId, tailLines) {
168
- const args = ['logs', '-f', '--tail', String(tailLines), containerId];
169
- const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
170
- return child;
171
- }
172
-
173
- function fetchContainerLogs(containerId, tailLines) {
174
- const child = spawn('docker', ['logs', '--tail', String(tailLines), containerId], {
175
- stdio: ['ignore', 'pipe', 'pipe'],
176
- });
177
- return child;
178
- }
179
-
180
- function restartService(file, service) {
181
- const cwd = path.dirname(path.resolve(file));
182
- const args = ['compose', '-f', path.resolve(file), 'restart', service];
183
- const child = spawn('docker', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: false });
184
- return child;
185
- }
186
-
187
- function stopService(file, service) {
188
- const cwd = path.dirname(path.resolve(file));
189
- const args = ['compose', '-f', path.resolve(file), 'stop', service];
190
- const child = spawn('docker', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: false });
191
- return child;
192
- }
193
-
194
- function startService(file, service) {
195
- const cwd = path.dirname(path.resolve(file));
196
- const args = ['compose', '-f', path.resolve(file), 'start', service];
197
- const child = spawn('docker', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: false });
198
- return child;
199
- }
200
-
201
- function fetchContainerStats(containerIds) {
202
- const args = ['stats', '--no-stream', '--format', '{{json .}}', ...containerIds];
203
- const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
204
- return child;
205
- }
206
-
207
- function parseMemString(str) {
208
- if (!str) return 0;
209
- const match = str.match(/^([\d.]+)\s*(B|KiB|MiB|GiB|TiB|kB|MB|GB|TB)$/i);
210
- if (!match) return 0;
211
- const val = parseFloat(match[1]);
212
- const unit = match[2].toLowerCase();
213
- const multipliers = { b: 1, kib: 1024, mib: 1024 * 1024, gib: 1024 * 1024 * 1024, tib: 1024 * 1024 * 1024 * 1024, kb: 1000, mb: 1e6, gb: 1e9, tb: 1e12 };
214
- return val * (multipliers[unit] || 1);
215
- }
216
-
217
- function parseStatsLine(jsonStr) {
218
- try {
219
- const obj = JSON.parse(jsonStr);
220
- const cpuPercent = parseFloat((obj.CPUPerc || '').replace('%', '')) || 0;
221
- const memUsageStr = (obj.MemUsage || '').split('/')[0].trim();
222
- const memUsageBytes = parseMemString(memUsageStr);
223
- const id = obj.ID || '';
224
- const name = obj.Name || '';
225
- return { id, name, cpuPercent, memUsageBytes };
226
- } catch {
227
- return null;
228
- }
229
- }
230
-
231
- module.exports = { listServices, getStatuses, rebuildService, restartService, stopService, startService, tailLogs, getContainerId, tailContainerLogs, fetchContainerLogs, fetchContainerStats, parseStatsLine, parseMemString };
package/lib/renderer.js DELETED
@@ -1,447 +0,0 @@
1
- 'use strict';
2
-
3
- const { statusKey, MODE } = require('./state');
4
-
5
- const ESC = '\x1b[';
6
- const RESET = `${ESC}0m`;
7
- const BOLD = `${ESC}1m`;
8
- const DIM = `${ESC}2m`;
9
- const REVERSE = `${ESC}7m`;
10
- const FG_GREEN = `${ESC}32m`;
11
- const FG_YELLOW = `${ESC}33m`;
12
- const FG_RED = `${ESC}31m`;
13
- const FG_GRAY = `${ESC}90m`;
14
- const FG_CYAN = `${ESC}36m`;
15
- const FG_WHITE = `${ESC}37m`;
16
-
17
- const ITALIC = `${ESC}3m`;
18
- const BG_HIGHLIGHT = `${ESC}48;5;237m`;
19
-
20
- const LOGO = [
21
- ` ${ITALIC}${BOLD}${FG_CYAN}┌─┐┌─┐┌─┐┌─┐┌┬┐┌─┐┌─┐┌─┐┌─┐┌┐ ┬ ┌─┐${RESET}`,
22
- ` ${ITALIC}${BOLD}${FG_CYAN}├┬┘├┤ │ │ ││││├─┘│ │└─┐├─┤├┴┐│ ├┤${RESET}`,
23
- ` ${ITALIC}${BOLD}${FG_CYAN}┴└─└─┘└─┘└─┘┴ ┴┴ └─┘└─┘┴ ┴└─┘┴─┘└─┘${RESET}`,
24
- ``,
25
- ` ${DIM}docker compose manager${RESET}`,
26
- ``,
27
- ];
28
-
29
- function visLen(str) {
30
- return str.replace(/\x1b\[[0-9;]*m/g, '').length;
31
- }
32
-
33
- function padVisible(str, width) {
34
- const pad = Math.max(0, width - visLen(str));
35
- return str + ' '.repeat(pad);
36
- }
37
-
38
- function padVisibleStart(str, width) {
39
- const pad = Math.max(0, width - visLen(str));
40
- return ' '.repeat(pad) + str;
41
- }
42
-
43
- const PATTERN_COLORS = [FG_YELLOW, FG_RED, FG_CYAN, FG_WHITE];
44
-
45
- function patternLabel(pattern) {
46
- return pattern.replace(/^[\[\(\{<]/, '').replace(/[\]\)\}>]$/, '');
47
- }
48
-
49
- function parseTimestamp(ts) {
50
- if (!ts) return null;
51
- // Strip trailing timezone abbreviation (e.g., "UTC", "CET")
52
- const cleaned = ts.replace(/ [A-Z]{2,5}$/, '');
53
- const d = new Date(cleaned);
54
- return isNaN(d.getTime()) ? null : d;
55
- }
56
-
57
- function relativeTime(ts) {
58
- const date = parseTimestamp(ts);
59
- if (!date) return `${FG_GRAY}-${RESET}`;
60
- const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
61
- if (seconds < 0) return `${FG_GRAY}-${RESET}`;
62
- if (seconds < 60) return `${DIM}${seconds}s ago${RESET}`;
63
- const minutes = Math.floor(seconds / 60);
64
- if (minutes < 60) return `${DIM}${minutes}m ago${RESET}`;
65
- const hours = Math.floor(minutes / 60);
66
- if (hours < 24) return `${DIM}${hours}h ago${RESET}`;
67
- const days = Math.floor(hours / 24);
68
- return `${DIM}${days}d ago${RESET}`;
69
- }
70
-
71
- function clearScreen() {
72
- return `${ESC}2J${ESC}H${ESC}?25l`;
73
- }
74
-
75
- function showCursor() {
76
- return `${ESC}?25h`;
77
- }
78
-
79
- function statusIcon(status, isRebuilding, isRestarting, isStopping, isStarting) {
80
- if (isRebuilding || isRestarting || isStopping || isStarting) return `${FG_YELLOW}\u25CF${RESET}`;
81
- if (!status) return `${FG_GRAY}\u25CB${RESET}`;
82
-
83
- const { state, health } = status;
84
- if (state === 'running') {
85
- if (health === 'unhealthy') return `${FG_RED}\u25CF${RESET}`;
86
- return `${FG_GREEN}\u25CF${RESET}`;
87
- }
88
- if (state === 'restarting') return `${FG_YELLOW}\u25CF${RESET}`;
89
- return `${FG_GRAY}\u25CB${RESET}`;
90
- }
91
-
92
- function statusText(status, isRebuilding, isRestarting, isStopping, isStarting) {
93
- if (isStopping) return `${FG_YELLOW}STOPPING...${RESET}`;
94
- if (isStarting) return `${FG_YELLOW}STARTING...${RESET}`;
95
- if (isRestarting) return `${FG_YELLOW}RESTARTING...${RESET}`;
96
- if (isRebuilding) return `${FG_YELLOW}REBUILDING...${RESET}`;
97
- if (!status) return `${FG_GRAY}stopped${RESET}`;
98
-
99
- const { state, health } = status;
100
- let text = state;
101
- if (health && health !== 'none' && health !== '') {
102
- text += ` (${health})`;
103
- }
104
-
105
- if (state === 'running') {
106
- if (health === 'unhealthy') return `${FG_RED}${text}${RESET}`;
107
- return `${FG_GREEN}${text}${RESET}`;
108
- }
109
- if (state === 'exited') return `${FG_GRAY}${text}${RESET}`;
110
- if (state === 'restarting') return `${FG_YELLOW}${text}${RESET}`;
111
- return `${DIM}${text}${RESET}`;
112
- }
113
-
114
- function formatMem(bytes) {
115
- if (bytes <= 0) return '-';
116
- if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}K`;
117
- if (bytes < 1024 * 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))}M`;
118
- return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`;
119
- }
120
-
121
- function renderLegend(opts = {}) {
122
- const { logPanelActive = false, fullLogsActive = false, logsScrollMode = false, noCacheActive = false } = opts;
123
- const item = (text, active) => {
124
- if (active) return `${BG_HIGHLIGHT} ${text} ${RESET}`;
125
- return `${DIM}${text}${RESET}`;
126
- };
127
- if (logsScrollMode) {
128
- return [
129
- item('[Esc] back', false),
130
- item('[j/k] scroll', false),
131
- item('[G] bottom', false),
132
- item('[gg] top', false),
133
- item('[/] search', false),
134
- item('[n/N] next/prev', false),
135
- item('[Q]uit', false),
136
- ].join(' ');
137
- }
138
- return [
139
- item('Re[B]uild', false),
140
- item('[S]tart/restart', false),
141
- item('Sto[P]', false),
142
- item('[N]o cache', noCacheActive),
143
- item('[F]ull logs', fullLogsActive),
144
- item('[L]og panel', logPanelActive),
145
- item('[Q]uit', false),
146
- ].join(' ');
147
- }
148
-
149
- function renderListView(state) {
150
- const { columns = 80, rows = 24 } = process.stdout;
151
- const patterns = state.config.logScanPatterns || [];
152
- const buf = [];
153
-
154
- // Logo
155
- for (const line of LOGO) {
156
- buf.push(line);
157
- }
158
- const help = renderLegend({ logPanelActive: state.showBottomLogs, noCacheActive: state.noCache });
159
- buf.push(` ${FG_GRAY}${'─'.repeat(Math.max(0, columns - 2))}${RESET}`);
160
- buf.push(` ${help}`);
161
-
162
- const headerHeight = buf.length;
163
-
164
- // Build bottom panel content — show logs for the currently selected container
165
- const bottomBuf = [];
166
- if (state.showBottomLogs) {
167
- const selEntry = state.flatList[state.cursor];
168
- if (selEntry) {
169
- const sk = statusKey(selEntry.file, selEntry.service);
170
- const info = state.bottomLogLines.get(sk);
171
- if (info) {
172
- bottomBuf.push(` ${FG_GRAY}${'─'.repeat(Math.max(0, columns - 2))}${RESET}`);
173
- const actionColor = info.action === 'rebuilding' || info.action === 'restarting' || info.action === 'stopping' || info.action === 'starting' ? FG_YELLOW : FG_GREEN;
174
- let headerLine = ` ${actionColor}${info.action} ${BOLD}${info.service}${RESET}`;
175
- // Show search info
176
- const bq = state.bottomSearchQuery || '';
177
- if (bq && !state.bottomSearchActive) {
178
- const matchCount = info.lines.filter(l => l.toLowerCase().includes(bq.toLowerCase())).length;
179
- headerLine += matchCount > 0
180
- ? ` ${DIM}search: "${bq}" (${matchCount} match${matchCount !== 1 ? 'es' : ''})${RESET}`
181
- : ` ${FG_RED}search: "${bq}" (no matches)${RESET}`;
182
- }
183
- bottomBuf.push(headerLine);
184
-
185
- const searchQuery = bq && !state.bottomSearchActive ? bq : '';
186
-
187
- for (const line of info.lines) {
188
- let coloredLine = line.substring(0, columns - 4);
189
- // Highlight search query
190
- if (searchQuery) {
191
- const lowerLine = coloredLine.toLowerCase();
192
- const lowerQ = searchQuery.toLowerCase();
193
- if (lowerLine.includes(lowerQ)) {
194
- let result = '';
195
- let pos = 0;
196
- while (pos < coloredLine.length) {
197
- const idx = lowerLine.indexOf(lowerQ, pos);
198
- if (idx === -1) { result += coloredLine.substring(pos); break; }
199
- result += coloredLine.substring(pos, idx);
200
- result += `${REVERSE}${FG_YELLOW}${coloredLine.substring(idx, idx + searchQuery.length)}${RESET}${FG_GRAY}`;
201
- pos = idx + searchQuery.length;
202
- }
203
- coloredLine = result;
204
- }
205
- }
206
- // Highlight log scan patterns
207
- for (let pi = 0; pi < patterns.length; pi++) {
208
- const p = patterns[pi];
209
- if (coloredLine.includes(p)) {
210
- const color = PATTERN_COLORS[pi % PATTERN_COLORS.length];
211
- coloredLine = coloredLine.split(p).join(`${color}${p}${RESET}${FG_GRAY}`);
212
- }
213
- }
214
- bottomBuf.push(` ${FG_GRAY}${coloredLine}${RESET}`);
215
- }
216
-
217
- // Search prompt
218
- if (state.bottomSearchActive) {
219
- bottomBuf.push(`${BOLD}/${RESET}${state.bottomSearchQuery}${BOLD}_${RESET}`);
220
- }
221
- }
222
- }
223
- }
224
- const bottomHeight = bottomBuf.length;
225
-
226
- // Build all display lines
227
- const lines = [];
228
- let currentGroup = -1;
229
-
230
- for (let i = 0; i < state.flatList.length; i++) {
231
- const entry = state.flatList[i];
232
-
233
- // Group header
234
- if (entry.groupIdx !== currentGroup) {
235
- currentGroup = entry.groupIdx;
236
- const group = state.groups[entry.groupIdx];
237
- if (lines.length > 0) lines.push({ type: 'blank' });
238
- const label = ` ${BOLD}${group.label}${RESET}`;
239
- if (group.error) {
240
- lines.push({ type: 'header', text: `${label} ${FG_RED}(${group.error})${RESET}` });
241
- } else {
242
- lines.push({ type: 'header', text: label });
243
- }
244
- let colHeader = `${DIM} ${'SERVICE'.padEnd(24)} ${'STATUS'.padEnd(22)} ${'BUILT'.padEnd(12)} ${'RESTARTED'.padEnd(12)}`;
245
- for (const p of patterns) colHeader += patternLabel(p).padStart(5) + ' ';
246
- colHeader += ` ${'CPU/MEM'.padStart(16)} ${'PORTS'.padEnd(14)}`;
247
- lines.push({ type: 'colheader', text: colHeader + RESET });
248
- }
249
-
250
- const sk = statusKey(entry.file, entry.service);
251
- const st = state.statuses.get(sk);
252
- const rebuilding = state.rebuilding.has(sk);
253
- const restarting = state.restarting.has(sk);
254
- const stopping = state.stopping.has(sk);
255
- const starting = state.starting.has(sk);
256
- const icon = statusIcon(st, rebuilding, restarting, stopping, starting);
257
- const stext = statusText(st, rebuilding, restarting, stopping, starting);
258
- const name = entry.service.padEnd(24);
259
- const statusPadded = padVisible(stext, 22);
260
-
261
- // CPU/MEM column
262
- let cpuMemStr;
263
- const stats = state.containerStats ? state.containerStats.get(sk) : null;
264
- if (stats && st && st.state === 'running') {
265
- const cpu = stats.cpuPercent;
266
- const mem = stats.memUsageBytes;
267
- const cpuWarn = state.config.cpuWarnThreshold || 50;
268
- const cpuDanger = state.config.cpuDangerThreshold || 100;
269
- const memWarn = (state.config.memWarnThreshold || 512) * 1024 * 1024;
270
- const memDanger = (state.config.memDangerThreshold || 1024) * 1024 * 1024;
271
- let color = DIM;
272
- if (cpu > cpuDanger || mem > memDanger) color = FG_RED;
273
- else if (cpu > cpuWarn || mem > memWarn) color = FG_YELLOW;
274
- const cpuText = cpu.toFixed(1) + '%';
275
- const memText = formatMem(mem);
276
- cpuMemStr = padVisible(`${color}${cpuText} / ${memText}${RESET}`, 16);
277
- } else {
278
- cpuMemStr = padVisible(`${DIM}-${RESET}`, 16);
279
- }
280
-
281
- // Ports column
282
- let portsStr;
283
- if (st && st.ports && st.ports.length > 0) {
284
- const portsText = st.ports.map(p => p.published).join(' ');
285
- portsStr = padVisible(`${DIM}${portsText}${RESET}`, 14);
286
- } else {
287
- portsStr = padVisible(`${DIM}-${RESET}`, 14);
288
- }
289
-
290
- const built = padVisible(relativeTime(st ? st.createdAt : null), 12);
291
- const restarted = padVisible(relativeTime(st ? st.startedAt : null), 12);
292
- const pointer = i === state.cursor ? `${REVERSE}` : '';
293
- const endPointer = i === state.cursor ? `${RESET}` : '';
294
-
295
- let countsStr = '';
296
- const logCounts = state.logCounts.get(sk);
297
- for (let pi = 0; pi < patterns.length; pi++) {
298
- const count = logCounts ? (logCounts.get(patterns[pi]) || 0) : 0;
299
- const color = count > 0 ? PATTERN_COLORS[pi % PATTERN_COLORS.length] : DIM;
300
- const countText = count > 0 ? `${color}${count}${RESET}` : `${color}-${RESET}`;
301
- countsStr += padVisibleStart(countText, 5) + ' ';
302
- }
303
-
304
- lines.push({
305
- type: 'service',
306
- text: `${pointer} ${icon} ${FG_WHITE}${name}${RESET} ${statusPadded} ${built} ${restarted}${countsStr} ${cpuMemStr} ${portsStr}${endPointer}`,
307
- flatIdx: i,
308
- });
309
- }
310
-
311
- // Scrolling
312
- const availableRows = Math.max(3, rows - headerHeight - bottomHeight);
313
-
314
- // Find line index of cursor
315
- const cursorLineIdx = lines.findIndex(l => l.type === 'service' && l.flatIdx === state.cursor);
316
-
317
- // Adjust scroll offset
318
- if (cursorLineIdx < state.scrollOffset) {
319
- state.scrollOffset = cursorLineIdx;
320
- } else if (cursorLineIdx >= state.scrollOffset + availableRows) {
321
- state.scrollOffset = cursorLineIdx - availableRows + 1;
322
- }
323
- state.scrollOffset = Math.max(0, Math.min(lines.length - availableRows, state.scrollOffset));
324
-
325
- const visible = lines.slice(state.scrollOffset, state.scrollOffset + availableRows);
326
- for (const line of visible) {
327
- buf.push(line.text || '');
328
- }
329
-
330
- // Pad to push bottom panel to the bottom of the terminal
331
- const usedLines = buf.length + bottomHeight;
332
- const paddingNeeded = Math.max(0, rows - usedLines);
333
- for (let i = 0; i < paddingNeeded; i++) {
334
- buf.push('');
335
- }
336
-
337
- // Bottom panel
338
- buf.push(...bottomBuf);
339
-
340
- return buf.join('\n');
341
- }
342
-
343
- function truncateLine(str, maxWidth) {
344
- let visPos = 0;
345
- let rawPos = 0;
346
- while (rawPos < str.length) {
347
- if (str[rawPos] === '\x1b') {
348
- const match = str.substring(rawPos).match(/^\x1b\[[0-9;?]*[a-zA-Z]/);
349
- if (match) { rawPos += match[0].length; continue; }
350
- const oscMatch = str.substring(rawPos).match(/^\x1b\][^\x07]*\x07/);
351
- if (oscMatch) { rawPos += oscMatch[0].length; continue; }
352
- }
353
- if (visPos >= maxWidth) {
354
- return str.substring(0, rawPos) + RESET;
355
- }
356
- visPos++;
357
- rawPos++;
358
- }
359
- return str;
360
- }
361
-
362
- function highlightSearchInLine(line, query) {
363
- if (!query) return line;
364
- const lowerLine = line.toLowerCase();
365
- const lowerQuery = query.toLowerCase();
366
- let result = '';
367
- let pos = 0;
368
- while (pos < line.length) {
369
- const idx = lowerLine.indexOf(lowerQuery, pos);
370
- if (idx === -1) {
371
- result += line.substring(pos);
372
- break;
373
- }
374
- result += line.substring(pos, idx);
375
- result += `${REVERSE}${FG_YELLOW}${line.substring(idx, idx + query.length)}${RESET}`;
376
- pos = idx + query.length;
377
- }
378
- return result;
379
- }
380
-
381
- function renderLogView(state) {
382
- const { columns = 80, rows = 24 } = process.stdout;
383
- const buf = [];
384
-
385
- for (const line of LOGO) {
386
- buf.push(line);
387
- }
388
- buf.push(` ${FG_GRAY}${'─'.repeat(Math.max(0, columns - 2))}${RESET}`);
389
- buf.push(` ${renderLegend({ logsScrollMode: true })}`);
390
-
391
- const entry = state.flatList[state.cursor];
392
- const serviceName = entry ? entry.service : '???';
393
- const totalLines = state.logLines.length;
394
-
395
- let statusLine = ` ${FG_GREEN}full logs ${BOLD}${serviceName}${RESET}`;
396
- const scrollStatus = state.logAutoScroll
397
- ? `${FG_GREEN}live${RESET}`
398
- : `${FG_YELLOW}paused ${DIM}line ${Math.max(1, totalLines - state.logScrollOffset)} / ${totalLines}${RESET}`;
399
- statusLine += ` ${scrollStatus}`;
400
-
401
- // Show search match count
402
- if (state.logSearchQuery && state.logSearchMatches.length > 0) {
403
- statusLine += ` ${DIM}match ${state.logSearchMatchIdx + 1}/${state.logSearchMatches.length}${RESET}`;
404
- } else if (state.logSearchQuery && state.logSearchMatches.length === 0) {
405
- statusLine += ` ${FG_RED}no matches${RESET}`;
406
- }
407
- buf.push(statusLine);
408
-
409
- // Reserve 1 row for search prompt if active
410
- const bottomReserved = state.logSearchActive ? 1 : 0;
411
- const headerHeight = buf.length;
412
- const availableRows = Math.max(1, rows - headerHeight - bottomReserved);
413
-
414
- let endLine;
415
- if (state.logAutoScroll || state.logScrollOffset === 0) {
416
- endLine = totalLines;
417
- } else {
418
- endLine = Math.max(0, totalLines - state.logScrollOffset);
419
- }
420
- const startLine = Math.max(0, endLine - availableRows);
421
-
422
- const searchQuery = state.logSearchQuery || '';
423
- const matchSet = searchQuery ? new Set(state.logSearchMatches) : null;
424
-
425
- for (let i = startLine; i < endLine; i++) {
426
- let line = state.logLines[i];
427
- if (matchSet && matchSet.has(i)) {
428
- line = highlightSearchInLine(line, searchQuery);
429
- }
430
- buf.push(truncateLine(line, columns));
431
- }
432
-
433
- // Pad to fill screen
434
- const targetRows = rows - bottomReserved;
435
- for (let i = buf.length; i < targetRows; i++) {
436
- buf.push('');
437
- }
438
-
439
- // Search prompt at the bottom
440
- if (state.logSearchActive) {
441
- buf.push(`${BOLD}/${RESET}${state.logSearchQuery}${BOLD}_${RESET}`);
442
- }
443
-
444
- return buf.join('\n');
445
- }
446
-
447
- module.exports = { clearScreen, showCursor, renderListView, renderLogView };