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/devServer.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dev server manager.
|
|
5
|
+
*
|
|
6
|
+
* Each project gets at most one running `npm run dev` child process. stdout and
|
|
7
|
+
* stderr are piped back to the caller (the TUI log pane) instead of being
|
|
8
|
+
* attached to a terminal, and the first localhost URL found in the output is
|
|
9
|
+
* opened in the default browser.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const open = require('open');
|
|
13
|
+
const crossSpawn = require('cross-spawn');
|
|
14
|
+
const { extractLocalUrl, splitLines, stripAnsi, killTree } = require('./util');
|
|
15
|
+
|
|
16
|
+
const DEFAULT_DEV_COMMAND = 'npm run dev';
|
|
17
|
+
|
|
18
|
+
/** Fallback browser launcher (injectable so tests never open a real browser). */
|
|
19
|
+
async function defaultOpenBrowser(url) {
|
|
20
|
+
await open(url);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseCommand(command) {
|
|
24
|
+
const parts = String(command || '')
|
|
25
|
+
.trim()
|
|
26
|
+
.split(/\s+/)
|
|
27
|
+
.filter(Boolean);
|
|
28
|
+
return { bin: parts[0], args: parts.slice(1) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class DevServerManager {
|
|
32
|
+
/**
|
|
33
|
+
* @param {object} options
|
|
34
|
+
* @param {function} options.onLog (project, line, stream) => void
|
|
35
|
+
* @param {function} options.onState (project, state) => void
|
|
36
|
+
* @param {function} options.onExit (project, {code, signal, stoppedByUs}) => void
|
|
37
|
+
* @param {function} options.openBrowser (url, project) => Promise<void>
|
|
38
|
+
*/
|
|
39
|
+
constructor(options = {}) {
|
|
40
|
+
this.onLog = options.onLog || (() => {});
|
|
41
|
+
this.onState = options.onState || (() => {});
|
|
42
|
+
this.onExit = options.onExit || (() => {});
|
|
43
|
+
this.openBrowser = options.openBrowser || defaultOpenBrowser;
|
|
44
|
+
this.devCommand = options.devCommand || DEFAULT_DEV_COMMAND;
|
|
45
|
+
this.fallbackPort = options.fallbackPort || 3000;
|
|
46
|
+
// How long to wait for the process to print a URL before guessing one.
|
|
47
|
+
this.urlFallbackDelayMs = options.urlFallbackDelayMs || 9000;
|
|
48
|
+
// Give the HTTP listener a moment to bind before the browser hits it.
|
|
49
|
+
this.browserDelayMs = options.browserDelayMs || 1200;
|
|
50
|
+
this.servers = new Map();
|
|
51
|
+
this.lastExit = new Map();
|
|
52
|
+
this.autoOpenBrowser = options.autoOpenBrowser !== false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @returns {object|undefined} running entry for a project path */
|
|
56
|
+
get(projectPath) {
|
|
57
|
+
return this.servers.get(projectPath);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
isRunning(projectPath) {
|
|
61
|
+
return this.servers.has(projectPath);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get runningCount() {
|
|
65
|
+
return this.servers.size;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Start a project's dev server.
|
|
70
|
+
* @returns {{ok: boolean, entry?: object, error?: string, alreadyRunning?: boolean}}
|
|
71
|
+
*/
|
|
72
|
+
start(project, options = {}) {
|
|
73
|
+
if (this.servers.has(project.path)) {
|
|
74
|
+
return { ok: false, alreadyRunning: true, entry: this.servers.get(project.path), error: 'already running' };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const command = options.command || project.devCommand || this.devCommand;
|
|
78
|
+
const { bin, args } = parseCommand(command);
|
|
79
|
+
if (!bin) return { ok: false, error: `No dev command configured for ${project.name}.` };
|
|
80
|
+
|
|
81
|
+
const entry = {
|
|
82
|
+
project,
|
|
83
|
+
command,
|
|
84
|
+
child: null,
|
|
85
|
+
url: null,
|
|
86
|
+
urlGuess: false,
|
|
87
|
+
status: 'starting',
|
|
88
|
+
startedAt: Date.now(),
|
|
89
|
+
browserOpened: false,
|
|
90
|
+
stopping: false,
|
|
91
|
+
timers: [],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
let child;
|
|
95
|
+
try {
|
|
96
|
+
child = crossSpawn(bin, args, {
|
|
97
|
+
cwd: project.path,
|
|
98
|
+
env: {
|
|
99
|
+
...process.env,
|
|
100
|
+
// Keep output plain so it renders cleanly inside the TUI log pane,
|
|
101
|
+
// and stop frameworks from opening their own browser tab.
|
|
102
|
+
FORCE_COLOR: '0',
|
|
103
|
+
BROWSER: 'none',
|
|
104
|
+
},
|
|
105
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
106
|
+
windowsHide: true,
|
|
107
|
+
// Own process group on POSIX so we can kill the whole tree.
|
|
108
|
+
detached: process.platform !== 'win32',
|
|
109
|
+
});
|
|
110
|
+
} catch (err) {
|
|
111
|
+
return { ok: false, error: err.message };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
entry.child = child;
|
|
115
|
+
entry.pid = child.pid;
|
|
116
|
+
this.servers.set(project.path, entry);
|
|
117
|
+
this.log(project, `{bold}$${escapeTags(command)}{/bold} (cwd: ${project.path})`, 'system');
|
|
118
|
+
|
|
119
|
+
const carryOut = { rest: '' };
|
|
120
|
+
const carryErr = { rest: '' };
|
|
121
|
+
|
|
122
|
+
child.stdout.on('data', (chunk) => this.handleChunk(entry, chunk, carryOut, 'stdout'));
|
|
123
|
+
child.stderr.on('data', (chunk) => this.handleChunk(entry, chunk, carryErr, 'stderr'));
|
|
124
|
+
|
|
125
|
+
child.on('error', (err) => {
|
|
126
|
+
if (this.servers.get(project.path) === entry) this.servers.delete(project.path);
|
|
127
|
+
entry.status = 'error';
|
|
128
|
+
entry.error = err.message;
|
|
129
|
+
this.clearTimers(entry);
|
|
130
|
+
this.state(project, { status: 'error', pid: entry.pid, url: entry.url, error: err.message });
|
|
131
|
+
this.log(project, `{red-fg}cannot start "${command}": ${escapeTags(err.message)}{/red-fg}`, 'system');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
child.on('exit', (code, signal) => {
|
|
135
|
+
const stoppedByUs = entry.stopping;
|
|
136
|
+
if (this.servers.get(project.path) === entry) this.servers.delete(project.path);
|
|
137
|
+
this.clearTimers(entry);
|
|
138
|
+
entry.status = 'stopped';
|
|
139
|
+
this.lastExit.set(project.path, { code, signal, at: Date.now(), stoppedByUs });
|
|
140
|
+
this.state(project, { status: 'stopped', pid: entry.pid, url: entry.url, code, signal });
|
|
141
|
+
this.onExit(project, { code, signal, stoppedByUs });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
this.state(project, { status: 'starting', pid: entry.pid, url: null });
|
|
145
|
+
|
|
146
|
+
// Some dev servers are silent: fall back to an assumed port.
|
|
147
|
+
entry.timers.push(
|
|
148
|
+
setTimeout(() => {
|
|
149
|
+
if (!entry.url && this.servers.get(project.path) === entry) {
|
|
150
|
+
const port = project.port || this.fallbackPort;
|
|
151
|
+
this.log(
|
|
152
|
+
project,
|
|
153
|
+
`{yellow-fg}no localhost URL in the logs after ${Math.round(this.urlFallbackDelayMs / 1000)}s \u2014 assuming http://localhost:${port}{/yellow-fg}`,
|
|
154
|
+
'system'
|
|
155
|
+
);
|
|
156
|
+
this.handleUrl(entry, `http://localhost:${port}`, { guessed: true });
|
|
157
|
+
}
|
|
158
|
+
}, this.urlFallbackDelayMs)
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
return { ok: true, entry };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Consume raw stream data: split lines, look for the first local URL. */
|
|
165
|
+
handleChunk(entry, chunk, carry, stream) {
|
|
166
|
+
const lines = splitLines(chunk.toString('utf8'), carry);
|
|
167
|
+
for (const raw of lines) {
|
|
168
|
+
const line = stripAnsi(raw).replace(/\s+$/, '');
|
|
169
|
+
if (!line.trim()) continue;
|
|
170
|
+
if (!entry.url) {
|
|
171
|
+
const found = extractLocalUrl(line);
|
|
172
|
+
if (found) this.handleUrl(entry, found);
|
|
173
|
+
}
|
|
174
|
+
this.log(entry.project, line, stream);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
handleUrl(entry, url, { guessed = false } = {}) {
|
|
179
|
+
if (entry.url) return;
|
|
180
|
+
entry.url = url;
|
|
181
|
+
entry.urlGuess = guessed;
|
|
182
|
+
entry.status = 'running';
|
|
183
|
+
this.state(entry.project, { status: 'running', pid: entry.pid, url, guessed });
|
|
184
|
+
|
|
185
|
+
if (!this.autoOpenBrowser || entry.browserOpened) return;
|
|
186
|
+
entry.browserOpened = true;
|
|
187
|
+
|
|
188
|
+
const timer = setTimeout(async () => {
|
|
189
|
+
if (this.servers.get(entry.project.path) !== entry) return;
|
|
190
|
+
try {
|
|
191
|
+
await this.openBrowser(url, entry.project);
|
|
192
|
+
this.log(entry.project, `{green-fg}opened ${url} in your browser{/green-fg}`, 'system');
|
|
193
|
+
} catch (err) {
|
|
194
|
+
this.log(entry.project, `{yellow-fg}could not open a browser (${escapeTags(err.message)}) \u2014 visit ${url}{/yellow-fg}`, 'system');
|
|
195
|
+
}
|
|
196
|
+
}, guessed ? 0 : this.browserDelayMs);
|
|
197
|
+
entry.timers.push(timer);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Stop one dev server (and its children). */
|
|
201
|
+
stop(projectPath) {
|
|
202
|
+
const entry = this.servers.get(projectPath);
|
|
203
|
+
if (!entry) return false;
|
|
204
|
+
entry.stopping = true;
|
|
205
|
+
this.clearTimers(entry);
|
|
206
|
+
this.servers.delete(projectPath);
|
|
207
|
+
this.state(entry.project, { status: 'stopped', pid: entry.pid, url: entry.url });
|
|
208
|
+
killTree(entry.child);
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Stop everything \u2014 called when the TUI quits. */
|
|
213
|
+
stopAll() {
|
|
214
|
+
const paths = [...this.servers.keys()];
|
|
215
|
+
paths.forEach((projectPath) => this.stop(projectPath));
|
|
216
|
+
return paths.length;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
clearTimers(entry) {
|
|
220
|
+
entry.timers.forEach((timer) => clearTimeout(timer));
|
|
221
|
+
entry.timers = [];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
state(project, state) {
|
|
225
|
+
try {
|
|
226
|
+
this.onState(project, state);
|
|
227
|
+
} catch (_) {
|
|
228
|
+
/* never let a render error kill a child process */
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
log(project, line, stream) {
|
|
233
|
+
try {
|
|
234
|
+
this.onLog(project, line, stream);
|
|
235
|
+
} catch (_) {
|
|
236
|
+
/* ignore */
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** blessed treats `{`/`}` as markup \u2014 escape command strings for the log pane. */
|
|
242
|
+
function escapeTags(text) {
|
|
243
|
+
return String(text == null ? '' : text).replace(/\{/g, '{open}').replace(/\}/g, '{close}');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
module.exports = { DevServerManager, DEFAULT_DEV_COMMAND, defaultOpenBrowser, parseCommand };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* termdeck CLI.
|
|
5
|
+
*
|
|
6
|
+
* `termdeck` launch the dashboard (runs setup on first use)
|
|
7
|
+
* `termdeck --setup` re-run the setup wizard
|
|
8
|
+
* `termdeck --reset` delete the config file and run setup again
|
|
9
|
+
* `termdeck --list` print the configured projects and exit (no TUI)
|
|
10
|
+
* `termdeck --no-open` do not auto-open the browser for dev servers
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
|
|
15
|
+
const { getConfigPath, configExists, loadConfig, runSetupWizard } = require('./config');
|
|
16
|
+
|
|
17
|
+
const HELP = `
|
|
18
|
+
termdeck - a terminal dashboard for your local dev projects
|
|
19
|
+
|
|
20
|
+
Usage
|
|
21
|
+
$ termdeck [options]
|
|
22
|
+
|
|
23
|
+
Options
|
|
24
|
+
-h, --help Show this help
|
|
25
|
+
-V, --version Show the version
|
|
26
|
+
-s, --setup Re-run the interactive setup wizard
|
|
27
|
+
-r, --reset Delete the config file, then run setup again
|
|
28
|
+
-l, --list Print the configured projects and exit
|
|
29
|
+
--no-open Do not open a browser when a dev server starts
|
|
30
|
+
|
|
31
|
+
Keys (inside the dashboard)
|
|
32
|
+
up/down, j/k Select a project
|
|
33
|
+
d Start \`npm run dev\` and stream its logs into the pane
|
|
34
|
+
e Open the project in your editor in a NEW terminal window
|
|
35
|
+
a Open your coding agent in a NEW terminal window
|
|
36
|
+
x Stop the selected project's dev server
|
|
37
|
+
r Reload the config file
|
|
38
|
+
PgUp/PgDn, wheel Scroll the dev server logs (G to follow the tail again)
|
|
39
|
+
q Quit (stops every dev server it started)
|
|
40
|
+
|
|
41
|
+
Config
|
|
42
|
+
${getConfigPath()}
|
|
43
|
+
Override the location with the TERMDECK_CONFIG environment variable.
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
function parseArgs(argv = []) {
|
|
47
|
+
const args = { help: false, version: false, setup: false, reset: false, list: false, noOpen: false };
|
|
48
|
+
for (const raw of argv) {
|
|
49
|
+
const arg = String(raw);
|
|
50
|
+
switch (arg) {
|
|
51
|
+
case '-h':
|
|
52
|
+
case '--help':
|
|
53
|
+
args.help = true;
|
|
54
|
+
break;
|
|
55
|
+
case '-V':
|
|
56
|
+
case '--version':
|
|
57
|
+
args.version = true;
|
|
58
|
+
break;
|
|
59
|
+
case '-s':
|
|
60
|
+
case '--setup':
|
|
61
|
+
args.setup = true;
|
|
62
|
+
break;
|
|
63
|
+
case '-r':
|
|
64
|
+
case '--reset':
|
|
65
|
+
args.reset = true;
|
|
66
|
+
break;
|
|
67
|
+
case '-l':
|
|
68
|
+
case '--list':
|
|
69
|
+
args.list = true;
|
|
70
|
+
break;
|
|
71
|
+
case '--no-open':
|
|
72
|
+
args.noOpen = true;
|
|
73
|
+
break;
|
|
74
|
+
default:
|
|
75
|
+
args.unknown = arg;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return args;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function printProjects(config) {
|
|
82
|
+
const lines = [`Config: ${getConfigPath()}`, `Root: ${config.root}`, ''];
|
|
83
|
+
const nameWidth = Math.max(...config.projects.map((p) => p.name.length), 4) + 2;
|
|
84
|
+
lines.push(` ${'NAME'.padEnd(nameWidth)}${'STATUS'.padEnd(14)}DESCRIPTION / PATH`);
|
|
85
|
+
for (const project of config.projects) {
|
|
86
|
+
lines.push(` ${project.name.padEnd(nameWidth)}${project.status.padEnd(14)}${project.info}`);
|
|
87
|
+
lines.push(` ${' '.repeat(nameWidth)}${' '.repeat(14)}${project.path}`);
|
|
88
|
+
}
|
|
89
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function needsTTY(what) {
|
|
93
|
+
const interactive = process.stdin.isTTY && process.stdout.isTTY;
|
|
94
|
+
if (interactive) return false;
|
|
95
|
+
process.stderr.write(
|
|
96
|
+
`termdeck needs an interactive terminal to ${what}.\n` +
|
|
97
|
+
`Open a real terminal and run it again, or use \`termdeck --list\` to inspect the saved config.\n`
|
|
98
|
+
);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {string[]} argv
|
|
104
|
+
* @returns {Promise<number>} exit code (the dashboard keeps the event loop alive)
|
|
105
|
+
*/
|
|
106
|
+
async function main(argv = process.argv.slice(2)) {
|
|
107
|
+
const args = parseArgs(argv);
|
|
108
|
+
|
|
109
|
+
if (args.help) {
|
|
110
|
+
process.stdout.write(HELP);
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (args.version) {
|
|
115
|
+
// eslint-disable-next-line global-require
|
|
116
|
+
process.stdout.write(`${require('../package.json').version}\n`);
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (args.reset) {
|
|
121
|
+
const file = getConfigPath();
|
|
122
|
+
if (configExists()) {
|
|
123
|
+
fs.unlinkSync(file);
|
|
124
|
+
process.stdout.write(`Removed ${file}\n`);
|
|
125
|
+
} else {
|
|
126
|
+
process.stdout.write(`No config file at ${file}\n`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const existing = args.reset ? null : loadConfig({ onWarn: (message) => process.stderr.write(`${message}\n`) });
|
|
131
|
+
let config = existing;
|
|
132
|
+
|
|
133
|
+
if (args.list && !config) {
|
|
134
|
+
process.stderr.write(`No config found at ${getConfigPath()}. Run \`termdeck --setup\` in a real terminal first.\n`);
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (args.setup || args.reset || !config) {
|
|
139
|
+
if (needsTTY('run the setup wizard')) return 1;
|
|
140
|
+
config = await runSetupWizard({ existing });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!config.projects.length) {
|
|
144
|
+
process.stderr.write('No projects configured. Run `termdeck --setup`.\n');
|
|
145
|
+
return 1;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (args.list) {
|
|
149
|
+
printProjects(config);
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (needsTTY('render the dashboard')) return 1;
|
|
154
|
+
|
|
155
|
+
// Loaded lazily so `--help`, `--version` and `--list` stay fast and work
|
|
156
|
+
// even when blessed has no usable terminal.
|
|
157
|
+
// eslint-disable-next-line global-require
|
|
158
|
+
const { launchDashboard } = require('./dashboard');
|
|
159
|
+
launchDashboard(config, { autoOpen: !args.noOpen });
|
|
160
|
+
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = { main, parseArgs, printProjects, HELP, getConfigPath };
|
package/src/logView.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Scrollable log pane.
|
|
5
|
+
*
|
|
6
|
+
* Wraps a blessed-contrib `log` widget (which is a blessed `List`) and adds:
|
|
7
|
+
* - batched writes (dev servers can emit hundreds of lines per second and
|
|
8
|
+
* rebuilding the widget on every single line is far too slow),
|
|
9
|
+
* - follow-the-tail behaviour,
|
|
10
|
+
* - scroll back / pause with a "N new lines" indicator,
|
|
11
|
+
* - a bounded history so long running servers cannot eat all memory.
|
|
12
|
+
*
|
|
13
|
+
* Scrolling is implemented by trimming the tail of the visible slice rather
|
|
14
|
+
* than by touching blessed's internal childBase/childOffset, so it behaves
|
|
15
|
+
* identically on every blessed version. The widget is only ever asked to
|
|
16
|
+
* `setItems()` and `setLabel()`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const DEFAULT_MAX_LINES = 500;
|
|
20
|
+
|
|
21
|
+
class LogView {
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} widget blessed-contrib log widget
|
|
24
|
+
* @param {object} [options]
|
|
25
|
+
* @param {number} [options.maxLines] history size
|
|
26
|
+
* @param {number} [options.flushInterval] ms between repaints
|
|
27
|
+
* @param {function} [options.viewportHeight] visible row count (used to clamp scrolling)
|
|
28
|
+
* @param {function} [options.onChange] called after the widget changed
|
|
29
|
+
* @param {string} [options.label] base label
|
|
30
|
+
*/
|
|
31
|
+
constructor(widget, options = {}) {
|
|
32
|
+
this.widget = widget;
|
|
33
|
+
this.maxLines = options.maxLines || DEFAULT_MAX_LINES;
|
|
34
|
+
this.flushInterval = options.flushInterval || 100;
|
|
35
|
+
this.viewportHeight = options.viewportHeight || (() => (typeof widget.height === 'number' ? widget.height - 2 : 10));
|
|
36
|
+
this.onChange = options.onChange || (() => {});
|
|
37
|
+
this.baseLabel = options.label || ' logs ';
|
|
38
|
+
|
|
39
|
+
this.lines = [];
|
|
40
|
+
this.follow = true;
|
|
41
|
+
this.viewOffset = 0;
|
|
42
|
+
// While paused the visible history is frozen at this length, so incoming
|
|
43
|
+
// lines cannot shift what the user is reading underneath them.
|
|
44
|
+
this.frozenLength = null;
|
|
45
|
+
this.pendingNew = 0;
|
|
46
|
+
this.dirty = true;
|
|
47
|
+
this.lastLabel = null;
|
|
48
|
+
|
|
49
|
+
this.timer = setInterval(() => this.flush(), this.flushInterval);
|
|
50
|
+
if (this.timer.unref) this.timer.unref();
|
|
51
|
+
|
|
52
|
+
this.flush();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get paused() {
|
|
56
|
+
return !this.follow;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Append one already formatted (tagged) line. */
|
|
60
|
+
push(line) {
|
|
61
|
+
this.lines.push(String(line));
|
|
62
|
+
if (this.lines.length > this.maxLines) {
|
|
63
|
+
this.lines.splice(0, this.lines.length - this.maxLines);
|
|
64
|
+
if (this.viewOffset > 0) this.viewOffset = Math.min(this.viewOffset, this.lines.length);
|
|
65
|
+
}
|
|
66
|
+
if (!this.follow) this.pendingNew += 1;
|
|
67
|
+
this.dirty = true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Append many lines at once. */
|
|
71
|
+
pushAll(lines) {
|
|
72
|
+
lines.forEach((line) => this.push(line));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Replace the whole history. */
|
|
76
|
+
clear() {
|
|
77
|
+
this.lines = [];
|
|
78
|
+
this.viewOffset = 0;
|
|
79
|
+
this.pendingNew = 0;
|
|
80
|
+
this.follow = true;
|
|
81
|
+
this.frozenLength = null;
|
|
82
|
+
this.dirty = true;
|
|
83
|
+
this.flush();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Repaint if anything changed. Safe to call on a timer. */
|
|
87
|
+
flush() {
|
|
88
|
+
if (!this.dirty) return;
|
|
89
|
+
this.dirty = false;
|
|
90
|
+
|
|
91
|
+
if (this.follow) {
|
|
92
|
+
this.viewOffset = 0;
|
|
93
|
+
this.frozenLength = null;
|
|
94
|
+
this.pendingNew = 0;
|
|
95
|
+
this.render();
|
|
96
|
+
}
|
|
97
|
+
this.updateLabel();
|
|
98
|
+
this.onChange();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Number of lines that are part of the current (possibly frozen) view. */
|
|
102
|
+
visibleLength() {
|
|
103
|
+
if (this.follow) return this.lines.length;
|
|
104
|
+
const frozen = this.frozenLength == null ? this.lines.length : this.frozenLength;
|
|
105
|
+
return Math.min(frozen, this.lines.length);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
render() {
|
|
109
|
+
const end = this.visibleLength();
|
|
110
|
+
const visible = this.viewOffset > 0 ? this.lines.slice(0, Math.max(0, end - this.viewOffset)) : this.lines.slice(0, end);
|
|
111
|
+
this.widget.setItems(visible);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
clampOffset(offset) {
|
|
115
|
+
const viewport = Math.max(1, Number(this.viewportHeight()) || 1);
|
|
116
|
+
const maxOffset = Math.max(0, this.visibleLength() - viewport);
|
|
117
|
+
return Math.max(0, Math.min(maxOffset, offset));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Scroll back `amount` lines (pauses following). */
|
|
121
|
+
scrollUp(amount = 3) {
|
|
122
|
+
if (!this.lines.length) return;
|
|
123
|
+
if (this.follow) this.frozenLength = this.lines.length;
|
|
124
|
+
this.follow = false;
|
|
125
|
+
this.viewOffset = this.clampOffset(this.viewOffset + amount);
|
|
126
|
+
this.repaintNow();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Scroll towards the tail; reaches the tail -> resume following. */
|
|
130
|
+
scrollDown(amount = 3) {
|
|
131
|
+
if (this.follow) return;
|
|
132
|
+
this.viewOffset = Math.max(0, this.viewOffset - amount);
|
|
133
|
+
if (this.viewOffset === 0) {
|
|
134
|
+
this.follow = true;
|
|
135
|
+
this.frozenLength = null;
|
|
136
|
+
this.pendingNew = 0;
|
|
137
|
+
}
|
|
138
|
+
this.repaintNow();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Jump to the very top of the history (pauses following). */
|
|
142
|
+
scrollTop() {
|
|
143
|
+
if (!this.lines.length) return;
|
|
144
|
+
if (this.follow) this.frozenLength = this.lines.length;
|
|
145
|
+
this.follow = false;
|
|
146
|
+
this.viewOffset = this.clampOffset(this.visibleLength());
|
|
147
|
+
this.repaintNow();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Resume following the tail. */
|
|
151
|
+
followTail() {
|
|
152
|
+
this.follow = true;
|
|
153
|
+
this.frozenLength = null;
|
|
154
|
+
this.viewOffset = 0;
|
|
155
|
+
this.pendingNew = 0;
|
|
156
|
+
this.repaintNow();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Page up/down by roughly one screen. */
|
|
160
|
+
page(direction) {
|
|
161
|
+
const viewport = Math.max(1, Number(this.viewportHeight()) || 1);
|
|
162
|
+
if (direction < 0) this.scrollUp(viewport - 1);
|
|
163
|
+
else this.scrollDown(viewport - 1);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
repaintNow() {
|
|
167
|
+
this.render();
|
|
168
|
+
this.updateLabel();
|
|
169
|
+
this.onChange();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
label() {
|
|
173
|
+
if (this.paused) {
|
|
174
|
+
const newLines = this.pendingNew > 0 ? `, ${this.pendingNew} new` : '';
|
|
175
|
+
return `${this.baseLabel}paused${newLines} \u2014 G to follow`;
|
|
176
|
+
}
|
|
177
|
+
return this.baseLabel;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
updateLabel() {
|
|
181
|
+
if (!this.widget.setLabel) return;
|
|
182
|
+
const label = this.label();
|
|
183
|
+
if (label !== this.lastLabel) {
|
|
184
|
+
this.lastLabel = label;
|
|
185
|
+
this.widget.setLabel(label);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
destroy() {
|
|
190
|
+
clearInterval(this.timer);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = { LogView, DEFAULT_MAX_LINES };
|