termdeck-cli 1.0.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/README.md +233 -0
- package/bin/termdeck.js +22 -0
- package/package.json +39 -0
- package/src/config.js +427 -0
- package/src/dashboard.js +542 -0
- package/src/devServer.js +246 -0
- package/src/index.js +164 -0
- package/src/logView.js +194 -0
- package/src/terminal.js +175 -0
- package/src/util.js +218 -0
package/src/dashboard.js
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The termdeck TUI.
|
|
5
|
+
*
|
|
6
|
+
* Layout
|
|
7
|
+
* +--------------------------------------------------------------+
|
|
8
|
+
* | header: termdeck - N projects from <root> |
|
|
9
|
+
* +----------------+---------------------------------------------+
|
|
10
|
+
* | project list | selected project card |
|
|
11
|
+
* | (click to pick)| [d] Dev Server [e] Editor [a] Agent |
|
|
12
|
+
* | +---------------------------------------------+
|
|
13
|
+
* | | dev server logs (scrollable, mouse wheel) |
|
|
14
|
+
* +----------------+---------------------------------------------+
|
|
15
|
+
* | footer: hints / status messages |
|
|
16
|
+
* +--------------------------------------------------------------+
|
|
17
|
+
*
|
|
18
|
+
* Mouse support is enabled on the screen, and the three CTAs are clickable
|
|
19
|
+
* boxes: blessed emits `click` on mouseup for any element registered as
|
|
20
|
+
* clickable, and every CTA also has a keyboard shortcut.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const blessed = require('blessed');
|
|
24
|
+
const contrib = require('blessed-contrib');
|
|
25
|
+
|
|
26
|
+
const { DevServerManager } = require('./devServer');
|
|
27
|
+
const { openInNewTerminal } = require('./terminal');
|
|
28
|
+
const { LogView } = require('./logView');
|
|
29
|
+
const { STATUS_COLORS, loadConfig } = require('./config');
|
|
30
|
+
const { escapeBraces, truncate, timestamp } = require('./util');
|
|
31
|
+
|
|
32
|
+
const LAYOUT = {
|
|
33
|
+
headerHeight: 3,
|
|
34
|
+
cardHeight: 8,
|
|
35
|
+
buttonHeight: 3,
|
|
36
|
+
footerHeight: 1,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const CARD_TOP = LAYOUT.headerHeight;
|
|
40
|
+
const BUTTON_TOP = CARD_TOP + LAYOUT.cardHeight;
|
|
41
|
+
const PANEL_TOP = BUTTON_TOP + LAYOUT.buttonHeight;
|
|
42
|
+
const SIDEBAR_WIDTH_PCT = 0.3;
|
|
43
|
+
const SIDEBAR_WIDTH = '30%';
|
|
44
|
+
const MAIN_LEFT = '30%';
|
|
45
|
+
const MAIN_WIDTH = '70%';
|
|
46
|
+
|
|
47
|
+
const PROJECT_COLORS = ['cyan', 'green', 'yellow', 'magenta', 'red', 'white'];
|
|
48
|
+
|
|
49
|
+
const HINTS =
|
|
50
|
+
' {bold}↑/↓{/bold} select {bold}d{/bold} dev server {bold}e{/bold} editor {bold}a{/bold} agent ' +
|
|
51
|
+
'{bold}x{/bold} stop server {bold}r{/bold} reload {bold}PgUp/PgDn{/bold} logs {bold}q{/bold} quit ';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {object} config parsed ~/.termdeck-config.json
|
|
55
|
+
* @param {object} [options]
|
|
56
|
+
* @param {boolean} [options.autoOpen] open the browser when a dev server reports a URL
|
|
57
|
+
* @param {object} [options.screenOptions] extra blessed screen options (headless tests)
|
|
58
|
+
* @returns {object} controller (useful for tests)
|
|
59
|
+
*/
|
|
60
|
+
function launchDashboard(config, options = {}) {
|
|
61
|
+
const screen = blessed.screen({
|
|
62
|
+
smartCSR: true,
|
|
63
|
+
fullUnicode: true,
|
|
64
|
+
title: 'termdeck',
|
|
65
|
+
mouse: true, // enables clicking the CTAs
|
|
66
|
+
dockBorders: true,
|
|
67
|
+
autoPadding: true,
|
|
68
|
+
...(options.screenOptions || {}),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const projects = config.projects;
|
|
72
|
+
const runStates = new Map();
|
|
73
|
+
const palette = new Map();
|
|
74
|
+
const status = { message: null, timer: null, quitArmed: false, quitTimer: null };
|
|
75
|
+
|
|
76
|
+
const colorFor = (project) => palette.get(project.path) || 'white';
|
|
77
|
+
|
|
78
|
+
function rebuildPalette() {
|
|
79
|
+
palette.clear();
|
|
80
|
+
projects.forEach((project, index) => palette.set(project.path, PROJECT_COLORS[index % PROJECT_COLORS.length]));
|
|
81
|
+
}
|
|
82
|
+
rebuildPalette();
|
|
83
|
+
|
|
84
|
+
/* ---------------------------------------------------------------- *
|
|
85
|
+
* Widgets
|
|
86
|
+
* ---------------------------------------------------------------- */
|
|
87
|
+
|
|
88
|
+
const header = blessed.box({
|
|
89
|
+
parent: screen,
|
|
90
|
+
top: 0,
|
|
91
|
+
left: 0,
|
|
92
|
+
width: '100%',
|
|
93
|
+
height: LAYOUT.headerHeight,
|
|
94
|
+
tags: true,
|
|
95
|
+
label: ' termdeck ',
|
|
96
|
+
border: { type: 'line' },
|
|
97
|
+
style: { border: { fg: 'cyan' }, label: { fg: 'cyan' }, fg: 'white' },
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const projectList = blessed.list({
|
|
101
|
+
parent: screen,
|
|
102
|
+
top: LAYOUT.headerHeight,
|
|
103
|
+
left: 0,
|
|
104
|
+
width: SIDEBAR_WIDTH,
|
|
105
|
+
bottom: LAYOUT.footerHeight,
|
|
106
|
+
label: ' projects ',
|
|
107
|
+
tags: true,
|
|
108
|
+
keys: true,
|
|
109
|
+
vi: false,
|
|
110
|
+
mouse: true,
|
|
111
|
+
interactive: true,
|
|
112
|
+
scrollable: true,
|
|
113
|
+
alwaysScroll: true,
|
|
114
|
+
border: { type: 'line' },
|
|
115
|
+
style: {
|
|
116
|
+
border: { fg: 'gray' },
|
|
117
|
+
label: { fg: 'white' },
|
|
118
|
+
selected: { bg: 'blue', fg: 'white', bold: true },
|
|
119
|
+
item: { fg: 'white', hover: { bg: 'gray' } },
|
|
120
|
+
},
|
|
121
|
+
items: [],
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const card = blessed.box({
|
|
125
|
+
parent: screen,
|
|
126
|
+
top: CARD_TOP,
|
|
127
|
+
left: MAIN_LEFT,
|
|
128
|
+
width: MAIN_WIDTH,
|
|
129
|
+
height: LAYOUT.cardHeight,
|
|
130
|
+
tags: true,
|
|
131
|
+
label: ' selected project ',
|
|
132
|
+
border: { type: 'line' },
|
|
133
|
+
style: { border: { fg: 'gray' }, label: { fg: 'white' }, fg: 'white' },
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const logBox = contrib.log({
|
|
137
|
+
parent: screen,
|
|
138
|
+
top: PANEL_TOP,
|
|
139
|
+
left: MAIN_LEFT,
|
|
140
|
+
width: MAIN_WIDTH,
|
|
141
|
+
bottom: LAYOUT.footerHeight,
|
|
142
|
+
label: ' dev server logs ',
|
|
143
|
+
tags: true,
|
|
144
|
+
border: { type: 'line' },
|
|
145
|
+
bufferLength: 600,
|
|
146
|
+
// The widget renders items top-down; LogView handles scroll-back itself.
|
|
147
|
+
keys: false,
|
|
148
|
+
mouse: false,
|
|
149
|
+
interactive: false,
|
|
150
|
+
style: {
|
|
151
|
+
border: { fg: 'gray' },
|
|
152
|
+
label: { fg: 'white' },
|
|
153
|
+
fg: 'white',
|
|
154
|
+
item: { fg: 'white' },
|
|
155
|
+
selected: { fg: 'white', bg: 'black' },
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const footer = blessed.box({
|
|
160
|
+
parent: screen,
|
|
161
|
+
bottom: 0,
|
|
162
|
+
left: 0,
|
|
163
|
+
width: '100%',
|
|
164
|
+
height: LAYOUT.footerHeight,
|
|
165
|
+
tags: true,
|
|
166
|
+
style: { fg: 'white', bg: 'blue' },
|
|
167
|
+
content: HINTS,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const logView = new LogView(logBox, {
|
|
171
|
+
maxLines: 800,
|
|
172
|
+
flushInterval: 120,
|
|
173
|
+
viewportHeight: () => Math.max(1, screen.rows - PANEL_TOP - LAYOUT.footerHeight - 2),
|
|
174
|
+
onChange: () => screen.render(),
|
|
175
|
+
label: ' dev server logs ',
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
/** The three CTAs. `clickable: true` is what makes blessed deliver `click`. */
|
|
179
|
+
function makeButton({ left, width, label, color, onPress }) {
|
|
180
|
+
const button = blessed.box({
|
|
181
|
+
parent: screen,
|
|
182
|
+
top: BUTTON_TOP,
|
|
183
|
+
left,
|
|
184
|
+
width,
|
|
185
|
+
height: LAYOUT.buttonHeight,
|
|
186
|
+
content: `{bold}${label}{/bold}`,
|
|
187
|
+
align: 'center',
|
|
188
|
+
valign: 'middle',
|
|
189
|
+
tags: true,
|
|
190
|
+
clickable: true,
|
|
191
|
+
autoFocus: false, // keep keyboard focus on the project list
|
|
192
|
+
border: { type: 'line' },
|
|
193
|
+
style: { fg: 'black', bg: color, border: { fg: color } },
|
|
194
|
+
effects: { hover: { bg: 'white', fg: 'black' } },
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
button.on('click', () => {
|
|
198
|
+
try {
|
|
199
|
+
onPress();
|
|
200
|
+
} catch (err) {
|
|
201
|
+
setStatus(`Error: ${err.message}`);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
return button;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const buttons = {
|
|
209
|
+
dev: makeButton({ left: MAIN_LEFT, width: '23%', label: '▶ Dev Server', color: 'green', onPress: () => startDevServer() }),
|
|
210
|
+
editor: makeButton({ left: '53%', width: '23%', label: '</> Editor', color: 'blue', onPress: () => openTool('editor') }),
|
|
211
|
+
agent: makeButton({ left: '76%', width: '24%', label: '☕ Agent', color: 'magenta', onPress: () => openTool('agent') }),
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/* ---------------------------------------------------------------- *
|
|
215
|
+
* Dev servers
|
|
216
|
+
* ---------------------------------------------------------------- */
|
|
217
|
+
|
|
218
|
+
const servers = new DevServerManager({
|
|
219
|
+
devCommand: config.devCommand,
|
|
220
|
+
autoOpenBrowser: options.autoOpen !== undefined ? options.autoOpen : config.openBrowser !== false,
|
|
221
|
+
fallbackPort: config.fallbackPort || 3000,
|
|
222
|
+
onLog: (project, line, stream) => appendLog(project, line, stream),
|
|
223
|
+
onState: (project, state) => {
|
|
224
|
+
runStates.set(project.path, state);
|
|
225
|
+
refreshList();
|
|
226
|
+
updateCard();
|
|
227
|
+
},
|
|
228
|
+
onExit: (project, info) => {
|
|
229
|
+
const detail = info.code === null || info.code === undefined ? `signal ${info.signal}` : `exit code ${info.code}`;
|
|
230
|
+
appendLog(project, `{gray-fg}dev server stopped (${detail}){/gray-fg}`, 'system');
|
|
231
|
+
refreshList();
|
|
232
|
+
updateCard();
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
/* ---------------------------------------------------------------- *
|
|
237
|
+
* Rendering helpers
|
|
238
|
+
* ---------------------------------------------------------------- */
|
|
239
|
+
|
|
240
|
+
function listItems() {
|
|
241
|
+
// Wide enough for the longest status tag, "[Experimental]".
|
|
242
|
+
const statusWidth = 14;
|
|
243
|
+
const inner = Math.max(12, Math.floor(screen.cols * SIDEBAR_WIDTH_PCT) - 3);
|
|
244
|
+
const nameWidth = Math.max(6, inner - statusWidth - 2);
|
|
245
|
+
|
|
246
|
+
return projects.map((project) => {
|
|
247
|
+
const color = STATUS_COLORS[project.status] || 'white';
|
|
248
|
+
const state = runStates.get(project.path);
|
|
249
|
+
const running = state && (state.status === 'running' || state.status === 'starting');
|
|
250
|
+
const dot = running
|
|
251
|
+
? state.status === 'running'
|
|
252
|
+
? '{green-fg}●{/green-fg}'
|
|
253
|
+
: '{yellow-fg}●{/yellow-fg}'
|
|
254
|
+
: '{gray-fg}○{/gray-fg}';
|
|
255
|
+
const name = escapeBraces(truncate(project.name, nameWidth));
|
|
256
|
+
return `${dot} ${name} {${color}-fg}[${project.status}]{/${color}-fg}`;
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function devStateLine(project, state) {
|
|
261
|
+
if (state && (state.status === 'running' || state.status === 'starting')) {
|
|
262
|
+
const where = state.url ? ` \u2192 ${escapeBraces(state.url)}` : ' \u2014 waiting for a localhost URL\u2026';
|
|
263
|
+
return ` {green-fg}● dev server ${state.status} (pid ${state.pid}){/green-fg}${where}`;
|
|
264
|
+
}
|
|
265
|
+
if (state && state.status === 'error') {
|
|
266
|
+
return ` {red-fg}● ${escapeBraces(truncate(state.error || 'failed to start', 60))}{/red-fg}`;
|
|
267
|
+
}
|
|
268
|
+
const last = servers.lastExit.get(project.path);
|
|
269
|
+
if (last) {
|
|
270
|
+
const detail = last.code === null || last.code === undefined ? `signal ${last.signal}` : `exit code ${last.code}`;
|
|
271
|
+
return ` {gray-fg}○ dev server stopped (${detail}){/gray-fg}`;
|
|
272
|
+
}
|
|
273
|
+
return ' {gray-fg}○ dev server not running{/gray-fg}';
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function updateCard() {
|
|
277
|
+
const project = selectedProject();
|
|
278
|
+
if (!project) {
|
|
279
|
+
card.setContent(' {gray-fg}No projects configured. Run `termdeck --setup`.{/gray-fg}');
|
|
280
|
+
screen.render();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const state = runStates.get(project.path) || { status: 'idle' };
|
|
285
|
+
const color = STATUS_COLORS[project.status] || 'white';
|
|
286
|
+
const inner = Math.max(20, Math.floor(screen.cols * (1 - SIDEBAR_WIDTH_PCT)) - 4);
|
|
287
|
+
|
|
288
|
+
const lines = [
|
|
289
|
+
` {bold}${escapeBraces(truncate(project.name, 40))}{/bold} {${color}-fg}{bold}● ${project.status}{/bold}{/${color}-fg}`,
|
|
290
|
+
` {gray-fg}${escapeBraces(truncate(project.path, inner))}{/gray-fg}`,
|
|
291
|
+
` ${escapeBraces(truncate(project.info || '(no description)', inner - 2))}`,
|
|
292
|
+
` {gray-fg}editor:{/gray-fg} ${escapeBraces(project.editorCommand || config.editorCommand)} {gray-fg}agent:{/gray-fg} ${escapeBraces(project.agentCommand || config.agentCommand)}`,
|
|
293
|
+
devStateLine(project, state),
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
card.setContent(lines.join('\n'));
|
|
297
|
+
card.setLabel(` ${project.name} `);
|
|
298
|
+
screen.render();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function updateHeader() {
|
|
302
|
+
const running = servers.runningCount;
|
|
303
|
+
header.setContent(
|
|
304
|
+
` {bold}termdeck{/bold} {gray-fg}${projects.length} project${projects.length === 1 ? '' : 's'} · ${escapeBraces(config.root)}{/gray-fg}` +
|
|
305
|
+
`${running ? ` {green-fg}● ${running} dev server${running === 1 ? '' : 's'} running{/green-fg}` : ''}`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function refreshList() {
|
|
310
|
+
const selected = projectList.selected;
|
|
311
|
+
projectList.setItems(listItems());
|
|
312
|
+
if (typeof selected === 'number' && selected < projects.length) projectList.select(selected);
|
|
313
|
+
updateHeader();
|
|
314
|
+
screen.render();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function selectedProject() {
|
|
318
|
+
if (!projects.length) return null;
|
|
319
|
+
const index = Math.min(Math.max(projectList.selected || 0, 0), projects.length - 1);
|
|
320
|
+
return projects[index];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function setStatus(message) {
|
|
324
|
+
if (status.timer) clearTimeout(status.timer);
|
|
325
|
+
status.message = message;
|
|
326
|
+
footer.setContent(` {bold}${escapeBraces(message)}{/bold}`);
|
|
327
|
+
screen.render();
|
|
328
|
+
|
|
329
|
+
status.timer = setTimeout(() => {
|
|
330
|
+
status.message = null;
|
|
331
|
+
footer.setContent(HINTS);
|
|
332
|
+
screen.render();
|
|
333
|
+
}, 6000);
|
|
334
|
+
if (status.timer.unref) status.timer.unref();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Log lines from child processes are raw text -> escape blessed markup. */
|
|
338
|
+
function appendLog(project, line, stream = 'stdout') {
|
|
339
|
+
const prefix = `{gray-fg}${timestamp()}{/gray-fg} {${colorFor(project)}-fg}${escapeBraces(truncate(project.name, 10))}{/${colorFor(project)}-fg}`;
|
|
340
|
+
if (stream === 'system') {
|
|
341
|
+
// Already contains termdeck's own blessed tags.
|
|
342
|
+
logView.push(`${prefix} {cyan-fg}[termdeck]{/cyan-fg} ${line}`);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const marker = stream === 'stderr' ? '{red-fg}✗{/red-fg} ' : '';
|
|
346
|
+
logView.push(`${prefix} ${marker}${escapeBraces(line)}`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/* ---------------------------------------------------------------- *
|
|
350
|
+
* Actions (the CTAs)
|
|
351
|
+
* ---------------------------------------------------------------- */
|
|
352
|
+
|
|
353
|
+
function startDevServer() {
|
|
354
|
+
const project = selectedProject();
|
|
355
|
+
if (!project) return;
|
|
356
|
+
|
|
357
|
+
const existing = servers.get(project.path);
|
|
358
|
+
if (existing) {
|
|
359
|
+
logView.followTail();
|
|
360
|
+
appendLog(project, `already running (pid ${existing.pid})`, 'system');
|
|
361
|
+
setStatus(`${project.name}: dev server already running (pid ${existing.pid}).`);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
setStatus(`Starting dev server for ${project.name}\u2026`);
|
|
366
|
+
logView.followTail();
|
|
367
|
+
const result = servers.start(project);
|
|
368
|
+
|
|
369
|
+
if (!result.ok) {
|
|
370
|
+
appendLog(project, `{red-fg}could not start: ${escapeBraces(result.error)}{/red-fg}`, 'system');
|
|
371
|
+
setStatus(`Could not start ${project.name}: ${result.error}`);
|
|
372
|
+
} else {
|
|
373
|
+
appendLog(project, `logs are streaming into this pane — press x to stop`, 'system');
|
|
374
|
+
}
|
|
375
|
+
refreshList();
|
|
376
|
+
updateCard();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function stopDevServer() {
|
|
380
|
+
const project = selectedProject();
|
|
381
|
+
if (!project) return;
|
|
382
|
+
if (!servers.isRunning(project.path)) {
|
|
383
|
+
setStatus(`${project.name}: no dev server is running.`);
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
servers.stop(project.path);
|
|
387
|
+
appendLog(project, `stopping dev server\u2026`, 'system');
|
|
388
|
+
setStatus(`Stopped the dev server for ${project.name}.`);
|
|
389
|
+
refreshList();
|
|
390
|
+
updateCard();
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function openTool(kind) {
|
|
395
|
+
const project = selectedProject();
|
|
396
|
+
if (!project) return;
|
|
397
|
+
|
|
398
|
+
const command = kind === 'editor'
|
|
399
|
+
? project.editorCommand || config.editorCommand
|
|
400
|
+
: project.agentCommand || config.agentCommand;
|
|
401
|
+
|
|
402
|
+
setStatus(`Opening ${kind} for ${project.name} in a new terminal window\u2026`);
|
|
403
|
+
appendLog(project, `opening ${kind} in a new terminal: ${escapeBraces(command)}`, 'system');
|
|
404
|
+
|
|
405
|
+
const result = await openInNewTerminal({ cwd: project.path, command });
|
|
406
|
+
|
|
407
|
+
if (result.ok) {
|
|
408
|
+
appendLog(project, `{green-fg}new ${escapeBraces(result.terminal)} window \u2192 ${escapeBraces(project.path)}{/green-fg}`, 'system');
|
|
409
|
+
setStatus(`Opened ${kind} in a new terminal window.`);
|
|
410
|
+
} else {
|
|
411
|
+
appendLog(project, `{red-fg}could not open a terminal: ${escapeBraces(result.error)}{/red-fg}`, 'system');
|
|
412
|
+
setStatus(`Could not open a terminal for ${project.name}.`);
|
|
413
|
+
}
|
|
414
|
+
return result;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function reloadConfig() {
|
|
418
|
+
const fresh = loadConfig();
|
|
419
|
+
if (!fresh) {
|
|
420
|
+
setStatus('Could not reload the config file.');
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
config.root = fresh.root;
|
|
425
|
+
config.devCommand = fresh.devCommand;
|
|
426
|
+
config.editorCommand = fresh.editorCommand;
|
|
427
|
+
config.agentCommand = fresh.agentCommand;
|
|
428
|
+
config.openBrowser = fresh.openBrowser;
|
|
429
|
+
projects.splice(0, projects.length, ...fresh.projects);
|
|
430
|
+
|
|
431
|
+
// Drop run states for projects that are gone.
|
|
432
|
+
for (const key of [...runStates.keys()]) {
|
|
433
|
+
if (!projects.some((p) => p.path === key)) runStates.delete(key);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
rebuildPalette();
|
|
437
|
+
projectList.select(0);
|
|
438
|
+
refreshList();
|
|
439
|
+
updateCard();
|
|
440
|
+
setStatus(`Reloaded ${projects.length} projects from ${config.root}`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function destroy() {
|
|
444
|
+
if (status.timer) clearTimeout(status.timer);
|
|
445
|
+
if (status.quitTimer) clearTimeout(status.quitTimer);
|
|
446
|
+
logView.destroy();
|
|
447
|
+
servers.stopAll();
|
|
448
|
+
try {
|
|
449
|
+
screen.destroy();
|
|
450
|
+
} catch (_) {
|
|
451
|
+
/* already gone */
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function quit() {
|
|
456
|
+
const running = servers.runningCount;
|
|
457
|
+
if (running > 0 && !status.quitArmed) {
|
|
458
|
+
status.quitArmed = true;
|
|
459
|
+
setStatus(`Press q again to quit — ${running} dev server${running === 1 ? '' : 's'} will be stopped.`);
|
|
460
|
+
status.quitTimer = setTimeout(() => {
|
|
461
|
+
status.quitArmed = false;
|
|
462
|
+
status.message = null;
|
|
463
|
+
footer.setContent(HINTS);
|
|
464
|
+
screen.render();
|
|
465
|
+
}, 4000);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
destroy();
|
|
470
|
+
process.exit(0);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/* ---------------------------------------------------------------- *
|
|
474
|
+
* Wiring
|
|
475
|
+
* ---------------------------------------------------------------- */
|
|
476
|
+
|
|
477
|
+
const selectProject = (item, index) => {
|
|
478
|
+
if (typeof index === 'number') updateCard();
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
// `select item` fires on arrow navigation and mouse clicks,
|
|
482
|
+
// `select`/`action` fire when pressing enter.
|
|
483
|
+
projectList.on('select item', selectProject);
|
|
484
|
+
projectList.on('select', selectProject);
|
|
485
|
+
projectList.on('action', selectProject);
|
|
486
|
+
projectList.on('cancel', () => updateCard());
|
|
487
|
+
|
|
488
|
+
screen.key(['q', 'C-c'], quit);
|
|
489
|
+
screen.key(['d'], () => startDevServer());
|
|
490
|
+
screen.key(['e'], () => openTool('editor'));
|
|
491
|
+
screen.key(['a'], () => openTool('agent'));
|
|
492
|
+
screen.key(['x'], () => stopDevServer());
|
|
493
|
+
screen.key(['r'], () => reloadConfig());
|
|
494
|
+
screen.key(['j'], () => projectList.down(1));
|
|
495
|
+
screen.key(['k'], () => projectList.up(1));
|
|
496
|
+
// NOTE: blessed reports uppercase letters as `S-g`, so 'G' alone never fires.
|
|
497
|
+
screen.key(['S-g', 'end'], () => logView.followTail());
|
|
498
|
+
screen.key(['pageup'], () => logView.page(-1));
|
|
499
|
+
screen.key(['pagedown'], () => logView.page(1));
|
|
500
|
+
screen.key(['S-pageup', 'home'], () => logView.scrollTop());
|
|
501
|
+
|
|
502
|
+
// Mouse wheel scrolls the log pane, no matter what the cursor is over.
|
|
503
|
+
screen.on('wheelup', () => logView.scrollUp(3));
|
|
504
|
+
screen.on('wheeldown', () => logView.scrollDown(3));
|
|
505
|
+
|
|
506
|
+
// Never let a stray exception leave orphan dev servers behind.
|
|
507
|
+
const onFatal = (err) => {
|
|
508
|
+
destroy();
|
|
509
|
+
// eslint-disable-next-line no-console
|
|
510
|
+
console.error('\ntermdeck crashed:', err && err.stack ? err.stack : err);
|
|
511
|
+
process.exit(1);
|
|
512
|
+
};
|
|
513
|
+
process.once('uncaughtException', onFatal);
|
|
514
|
+
const onSignal = () => {
|
|
515
|
+
destroy();
|
|
516
|
+
process.exit(0);
|
|
517
|
+
};
|
|
518
|
+
process.once('SIGINT', onSignal);
|
|
519
|
+
process.once('SIGTERM', onSignal);
|
|
520
|
+
|
|
521
|
+
/* ---------------------------------------------------------------- *
|
|
522
|
+
* Boot
|
|
523
|
+
* ---------------------------------------------------------------- */
|
|
524
|
+
|
|
525
|
+
projectList.focus();
|
|
526
|
+
refreshList();
|
|
527
|
+
updateCard();
|
|
528
|
+
appendLog({ name: 'termdeck', path: '__termdeck__' }, `{bold}termdeck{/bold} ready — ${projects.length} projects from ${escapeBraces(config.root)}`, 'system');
|
|
529
|
+
appendLog({ name: 'termdeck', path: '__termdeck__' }, `pick a project and press {bold}d{/bold} for the dev server, {bold}e{/bold} for your editor, {bold}a{/bold} for an agent.`, 'system');
|
|
530
|
+
screen.render();
|
|
531
|
+
|
|
532
|
+
return {
|
|
533
|
+
screen,
|
|
534
|
+
widgets: { header, projectList, card, logBox, footer, buttons },
|
|
535
|
+
servers,
|
|
536
|
+
logView,
|
|
537
|
+
runStates,
|
|
538
|
+
actions: { startDevServer, stopDevServer, openTool, reloadConfig, quit, destroy, selectedProject },
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
module.exports = { launchDashboard, LAYOUT, HINTS };
|