@aliou/pi-processes 0.10.9 → 0.11.1
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/extensions/processes/client.ts +24 -2
- package/extensions/processes/commands/overview.ts +1 -1
- package/extensions/processes/components/overview-component.ts +121 -51
- package/extensions/processes/config/migrations/001-v0-9-4-to-v0-10-0-config.ts +5 -8
- package/extensions/processes/config/types.ts +1 -1
- package/extensions/processes/handlers/commands.ts +21 -41
- package/extensions/processes/handlers/notifications.ts +3 -37
- package/extensions/processes/handlers/requests.ts +1 -54
- package/extensions/processes/handlers/subscriptions.ts +2 -35
- package/extensions/processes/hooks/event-bridge.ts +1 -1
- package/extensions/processes/index.ts +2 -2
- package/extensions/processes/notifications/service.ts +4 -1
- package/extensions/processes/notifications/types.ts +2 -2
- package/extensions/processes/tools/notify.ts +37 -130
- package/extensions/processes/tools/schema.ts +5 -2
- package/extensions/processes/tools/update/index.ts +15 -40
- package/extensions/processes-debug/index.ts +67 -0
- package/extensions/processes-dock/client.ts +2 -2
- package/extensions/processes-dock/widget/setup.ts +13 -38
- package/extensions/processes-logs/client.ts +2 -2
- package/extensions/processes-logs/commands/logs.ts +1 -1
- package/extensions/processes-logs/components/log-file-viewer.ts +135 -8
- package/extensions/processes-logs/components/log-overlay-component.ts +156 -82
- package/extensions/processes-logs/logs-client.ts +2 -23
- package/extensions/shared/log-line.ts +49 -2
- package/{src → extensions/shared}/protocol/broadcasts.ts +1 -1
- package/{src → extensions/shared}/protocol/channels.ts +1 -0
- package/{src → extensions/shared}/protocol/commands.ts +12 -1
- package/{src → extensions/shared}/protocol/index.ts +2 -0
- package/{src → extensions/shared}/protocol/notifications.ts +1 -1
- package/{src → extensions/shared}/protocol/requests.ts +1 -1
- package/extensions/shared/shortcut-hints.ts +150 -0
- package/extensions/shared/shortcuts-overlay.ts +229 -0
- package/extensions/shared/truncate.ts +189 -0
- package/package.json +3 -3
- package/src/utils/command-executor.ts +2 -1
- package/extensions/shared/output-payload.ts +0 -28
- package/src/get-manager.ts +0 -15
- package/src/utils/is-record.ts +0 -3
- /package/{src → extensions/shared}/protocol/logs.ts +0 -0
|
@@ -9,10 +9,16 @@ import {
|
|
|
9
9
|
displayTextOf,
|
|
10
10
|
type LogLineEmphasis,
|
|
11
11
|
renderLogLine,
|
|
12
|
+
renderLogLineWrap,
|
|
12
13
|
} from "../../shared/log-line";
|
|
13
14
|
import { truncateToWidth } from "../../shared/truncate";
|
|
14
15
|
import type { ProcessLogLine } from "../logs-client";
|
|
15
16
|
|
|
17
|
+
interface DisplayRow {
|
|
18
|
+
text: string;
|
|
19
|
+
logicalIndex: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
16
22
|
export type StreamFilter = "both" | "stdout" | "stderr";
|
|
17
23
|
|
|
18
24
|
interface LogFileViewerOptions {
|
|
@@ -30,6 +36,8 @@ export class LogFileViewer {
|
|
|
30
36
|
private searchMatches: number[] = [];
|
|
31
37
|
private searchCurrentMatch = -1;
|
|
32
38
|
private centerTarget: number | null = null;
|
|
39
|
+
private wrapEnabled = false;
|
|
40
|
+
private lastRenderWidth = 0;
|
|
33
41
|
private readonly notifyLines = new Set<string>();
|
|
34
42
|
|
|
35
43
|
constructor(
|
|
@@ -59,12 +67,11 @@ export class LogFileViewer {
|
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
scrollBy(delta: number): void {
|
|
62
|
-
const visible = this.visibleLines();
|
|
63
70
|
this.follow = false;
|
|
64
|
-
this.anchorEnd ??=
|
|
71
|
+
this.anchorEnd ??= this.totalDisplayRows(0);
|
|
65
72
|
this.anchorEnd = Math.max(
|
|
66
73
|
0,
|
|
67
|
-
Math.min(
|
|
74
|
+
Math.min(this.totalDisplayRows(0), this.anchorEnd - delta),
|
|
68
75
|
);
|
|
69
76
|
}
|
|
70
77
|
|
|
@@ -75,12 +82,12 @@ export class LogFileViewer {
|
|
|
75
82
|
|
|
76
83
|
scrollToBottom(): void {
|
|
77
84
|
this.follow = false;
|
|
78
|
-
this.anchorEnd = this.
|
|
85
|
+
this.anchorEnd = this.totalDisplayRows(0);
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
toggleFollow(): boolean {
|
|
82
89
|
this.follow = !this.follow;
|
|
83
|
-
this.anchorEnd = this.follow ? null : this.
|
|
90
|
+
this.anchorEnd = this.follow ? null : this.totalDisplayRows(0);
|
|
84
91
|
return this.follow;
|
|
85
92
|
}
|
|
86
93
|
|
|
@@ -88,6 +95,18 @@ export class LogFileViewer {
|
|
|
88
95
|
return this.follow;
|
|
89
96
|
}
|
|
90
97
|
|
|
98
|
+
toggleWrap(): boolean {
|
|
99
|
+
this.wrapEnabled = !this.wrapEnabled;
|
|
100
|
+
// Reset anchor so the viewport snaps to the latest content in the new
|
|
101
|
+
// display-row space (row counts change when wrap toggles).
|
|
102
|
+
this.anchorEnd = this.follow ? null : this.totalDisplayRows(0);
|
|
103
|
+
return this.wrapEnabled;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
isWrapEnabled(): boolean {
|
|
107
|
+
return this.wrapEnabled;
|
|
108
|
+
}
|
|
109
|
+
|
|
91
110
|
cycleStreamFilter(): StreamFilter {
|
|
92
111
|
this.streamFilter =
|
|
93
112
|
this.streamFilter === "both"
|
|
@@ -95,7 +114,7 @@ export class LogFileViewer {
|
|
|
95
114
|
: this.streamFilter === "stdout"
|
|
96
115
|
? "stderr"
|
|
97
116
|
: "both";
|
|
98
|
-
this.anchorEnd = this.
|
|
117
|
+
this.anchorEnd = this.totalDisplayRows(0);
|
|
99
118
|
this.refreshMatches();
|
|
100
119
|
return this.streamFilter;
|
|
101
120
|
}
|
|
@@ -106,7 +125,7 @@ export class LogFileViewer {
|
|
|
106
125
|
|
|
107
126
|
setSearch(query: string): void {
|
|
108
127
|
this.follow = false;
|
|
109
|
-
this.anchorEnd ??= this.
|
|
128
|
+
this.anchorEnd ??= this.totalDisplayRows(0);
|
|
110
129
|
this.searchQuery = query;
|
|
111
130
|
this.refreshMatches();
|
|
112
131
|
if (this.searchMatches.length > 0) {
|
|
@@ -168,11 +187,24 @@ export class LogFileViewer {
|
|
|
168
187
|
}
|
|
169
188
|
|
|
170
189
|
render(width: number, height: number): string[] {
|
|
190
|
+
this.lastRenderWidth = width;
|
|
171
191
|
const visible = this.visibleLines();
|
|
172
192
|
if (visible.length === 0) {
|
|
173
193
|
return this.renderEmpty(width, height);
|
|
174
194
|
}
|
|
175
195
|
|
|
196
|
+
if (!this.wrapEnabled) {
|
|
197
|
+
return this.renderTruncated(visible, width, height);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return this.renderWrapped(visible, width, height);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private renderTruncated(
|
|
204
|
+
visible: ProcessLogLine[],
|
|
205
|
+
width: number,
|
|
206
|
+
height: number,
|
|
207
|
+
): string[] {
|
|
176
208
|
if (this.centerTarget !== null) {
|
|
177
209
|
const half = Math.floor(height / 2);
|
|
178
210
|
this.anchorEnd = Math.min(visible.length, this.centerTarget + half + 1);
|
|
@@ -186,6 +218,11 @@ export class LogFileViewer {
|
|
|
186
218
|
visible.length,
|
|
187
219
|
Math.max(Math.min(height, visible.length), rawEnd),
|
|
188
220
|
);
|
|
221
|
+
// Write the clamped end back so the anchor always matches the screen.
|
|
222
|
+
// Otherwise scrolling mutates a stale anchor while the clamp above
|
|
223
|
+
// swallows the change, leaving a dead zone of up to a screenful of
|
|
224
|
+
// scroll keypresses.
|
|
225
|
+
if (!this.follow && this.anchorEnd !== null) this.anchorEnd = end;
|
|
189
226
|
const start = Math.max(0, end - height);
|
|
190
227
|
const matchSet = new Set(this.searchMatches);
|
|
191
228
|
const currentMatchIndex =
|
|
@@ -211,12 +248,81 @@ export class LogFileViewer {
|
|
|
211
248
|
return rendered.slice(-height);
|
|
212
249
|
}
|
|
213
250
|
|
|
251
|
+
private renderWrapped(
|
|
252
|
+
visible: ProcessLogLine[],
|
|
253
|
+
width: number,
|
|
254
|
+
height: number,
|
|
255
|
+
): string[] {
|
|
256
|
+
// Build a flat list of display rows from all visible logical lines.
|
|
257
|
+
// Each entry carries its source logical-line index so emphasis can be
|
|
258
|
+
// applied per logical line (not per wrapped chunk).
|
|
259
|
+
const matchSet = new Set(this.searchMatches);
|
|
260
|
+
const currentMatchIndex =
|
|
261
|
+
this.searchCurrentMatch >= 0
|
|
262
|
+
? this.searchMatches[this.searchCurrentMatch]
|
|
263
|
+
: undefined;
|
|
264
|
+
|
|
265
|
+
const rows: DisplayRow[] = [];
|
|
266
|
+
// Track the starting display-row offset of each logical line so we can
|
|
267
|
+
// resolve centerTarget (a logical-line index) to a display row.
|
|
268
|
+
const logicalToDisplayRow: number[] = [];
|
|
269
|
+
|
|
270
|
+
for (let index = 0; index < visible.length; index++) {
|
|
271
|
+
logicalToDisplayRow[index] = rows.length;
|
|
272
|
+
const line = visible[index];
|
|
273
|
+
const emphasis: LogLineEmphasis =
|
|
274
|
+
index === currentMatchIndex
|
|
275
|
+
? "search-current"
|
|
276
|
+
: matchSet.has(index)
|
|
277
|
+
? "search"
|
|
278
|
+
: this.notifyLines.has(line.text) ||
|
|
279
|
+
this.notifyLines.has(displayTextOf(line))
|
|
280
|
+
? "notify"
|
|
281
|
+
: "none";
|
|
282
|
+
const wrapped = renderLogLineWrap(line, {
|
|
283
|
+
theme: this.theme,
|
|
284
|
+
width,
|
|
285
|
+
emphasis,
|
|
286
|
+
});
|
|
287
|
+
for (const text of wrapped) {
|
|
288
|
+
rows.push({ text, logicalIndex: index });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const totalDisplayRows = rows.length;
|
|
293
|
+
|
|
294
|
+
// Resolve centerTarget from logical-line index to display-row index.
|
|
295
|
+
if (this.centerTarget !== null) {
|
|
296
|
+
const half = Math.floor(height / 2);
|
|
297
|
+
const displayRow = logicalToDisplayRow[this.centerTarget] ?? 0;
|
|
298
|
+
this.anchorEnd = Math.min(totalDisplayRows, displayRow + half + 1);
|
|
299
|
+
this.centerTarget = null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const rawEnd = this.follow
|
|
303
|
+
? totalDisplayRows
|
|
304
|
+
: (this.anchorEnd ?? totalDisplayRows);
|
|
305
|
+
const end = Math.min(
|
|
306
|
+
totalDisplayRows,
|
|
307
|
+
Math.max(Math.min(height, totalDisplayRows), rawEnd),
|
|
308
|
+
);
|
|
309
|
+
// Same anchor normalization as the truncated path: keep the stored
|
|
310
|
+
// anchor truthful so scrollBy starts from the rendered bottom edge.
|
|
311
|
+
if (!this.follow && this.anchorEnd !== null) this.anchorEnd = end;
|
|
312
|
+
const start = Math.max(0, end - height);
|
|
313
|
+
|
|
314
|
+
const rendered = rows.slice(start, end).map((row) => row.text);
|
|
315
|
+
|
|
316
|
+
while (rendered.length < height) rendered.unshift("");
|
|
317
|
+
return rendered.slice(-height);
|
|
318
|
+
}
|
|
319
|
+
|
|
214
320
|
getStatusParts(): { left: string[]; right: string[] } {
|
|
215
321
|
const dim = (value: string) => this.theme.fg("dim", value);
|
|
216
322
|
const accent = (value: string) => this.theme.fg("accent", value);
|
|
217
323
|
const error = (value: string) => this.theme.fg("error", value);
|
|
218
324
|
const visible = this.visibleLines();
|
|
219
|
-
const total = visible.length;
|
|
325
|
+
const total = this.wrapEnabled ? this.totalDisplayRows(0) : visible.length;
|
|
220
326
|
|
|
221
327
|
const left: string[] = [];
|
|
222
328
|
const search = this.getSearchInfo();
|
|
@@ -239,6 +345,7 @@ export class LogFileViewer {
|
|
|
239
345
|
right.push(dim(`${pct}% L${end}/${total}`));
|
|
240
346
|
}
|
|
241
347
|
if (this.streamFilter !== "both") right.push(dim(`[${this.streamFilter}]`));
|
|
348
|
+
if (this.wrapEnabled) right.push(dim("wrap"));
|
|
242
349
|
|
|
243
350
|
return { left, right };
|
|
244
351
|
}
|
|
@@ -257,6 +364,26 @@ export class LogFileViewer {
|
|
|
257
364
|
return this.lines.filter((line) => line.type === this.streamFilter);
|
|
258
365
|
}
|
|
259
366
|
|
|
367
|
+
/**
|
|
368
|
+
* Total display rows for the visible lines at the last render width.
|
|
369
|
+
* When wrapping is off (or no width is known), equals the logical-line
|
|
370
|
+
* count. The `fallbackWidth` is used before the first render.
|
|
371
|
+
*/
|
|
372
|
+
private totalDisplayRows(fallbackWidth: number): number {
|
|
373
|
+
if (!this.wrapEnabled) return this.visibleLines().length;
|
|
374
|
+
const width = this.lastRenderWidth || fallbackWidth;
|
|
375
|
+
if (width <= 0) return this.visibleLines().length;
|
|
376
|
+
let total = 0;
|
|
377
|
+
for (const line of this.visibleLines()) {
|
|
378
|
+
const wrapped = renderLogLineWrap(line, {
|
|
379
|
+
theme: this.theme,
|
|
380
|
+
width,
|
|
381
|
+
});
|
|
382
|
+
total += Math.max(1, wrapped.length);
|
|
383
|
+
}
|
|
384
|
+
return total;
|
|
385
|
+
}
|
|
386
|
+
|
|
260
387
|
private refreshMatches(): void {
|
|
261
388
|
if (!this.searchQuery) {
|
|
262
389
|
this.searchMatches = [];
|
|
@@ -4,21 +4,29 @@ import {
|
|
|
4
4
|
type Component,
|
|
5
5
|
Input,
|
|
6
6
|
Key,
|
|
7
|
-
|
|
7
|
+
parseKey,
|
|
8
8
|
type TUI,
|
|
9
9
|
visibleWidth,
|
|
10
10
|
} from "@earendil-works/pi-tui";
|
|
11
|
+
import { LIVE_STATUSES, type ProcessInfo } from "../../../src/types";
|
|
12
|
+
import { formatRuntime } from "../../../src/utils/format";
|
|
13
|
+
import { truncateForDisplay } from "../../shared/display-text";
|
|
14
|
+
import { renderProcessTab } from "../../shared/process-tabs";
|
|
11
15
|
import {
|
|
12
16
|
CHANNELS,
|
|
13
17
|
type ProcessesChangedPayload,
|
|
14
18
|
type ProcessProtocolConfig,
|
|
15
19
|
type ProcessProtocolNotificationPayload,
|
|
16
|
-
} from "
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
} from "../../shared/protocol";
|
|
21
|
+
import {
|
|
22
|
+
renderShortcutHints,
|
|
23
|
+
SHORTCUTS_KEY,
|
|
24
|
+
type ShortcutHint,
|
|
25
|
+
} from "../../shared/shortcut-hints";
|
|
26
|
+
import {
|
|
27
|
+
type ShortcutGroup,
|
|
28
|
+
showShortcutsOverlay,
|
|
29
|
+
} from "../../shared/shortcuts-overlay";
|
|
22
30
|
import { truncateToWidth } from "../../shared/truncate";
|
|
23
31
|
import { LineComponent, LinesComponent, RuleComponent } from "../../shared/ui";
|
|
24
32
|
import { requestProcessList } from "../client";
|
|
@@ -82,6 +90,8 @@ export class LogOverlayComponent implements Component {
|
|
|
82
90
|
* survive a round-trip. Mirrors the notifyMarkers persistence pattern.
|
|
83
91
|
*/
|
|
84
92
|
private readonly viewers = new Map<string, LogFileViewer>();
|
|
93
|
+
/** Disposer for the "?" shortcuts overlay, when open. */
|
|
94
|
+
private shortcutsHelp: (() => void) | null = null;
|
|
85
95
|
|
|
86
96
|
constructor(private readonly opts: LogOverlayOptions) {
|
|
87
97
|
this.configureSearchInput();
|
|
@@ -89,7 +99,7 @@ export class LogOverlayComponent implements Component {
|
|
|
89
99
|
this.refreshProcesses(opts.initialProcessId);
|
|
90
100
|
this.disposers.push(
|
|
91
101
|
opts.events.on(CHANNELS.CHANGED, (payload) => {
|
|
92
|
-
|
|
102
|
+
this.handleProcessesChanged(payload as ProcessesChangedPayload);
|
|
93
103
|
}),
|
|
94
104
|
);
|
|
95
105
|
this.disposers.push(
|
|
@@ -99,8 +109,9 @@ export class LogOverlayComponent implements Component {
|
|
|
99
109
|
);
|
|
100
110
|
}
|
|
101
111
|
|
|
102
|
-
private handleNotification(
|
|
103
|
-
|
|
112
|
+
private handleNotification(rawPayload: unknown): void {
|
|
113
|
+
const payload = rawPayload as ProcessProtocolNotificationPayload;
|
|
114
|
+
if (payload.kind !== "log_match" || !payload.logMatch) return;
|
|
104
115
|
const mark: NotifyMatchMark = {
|
|
105
116
|
pattern: payload.logMatch.pattern,
|
|
106
117
|
line: payload.logMatch.line,
|
|
@@ -144,6 +155,31 @@ export class LogOverlayComponent implements Component {
|
|
|
144
155
|
return panel.render(width);
|
|
145
156
|
}
|
|
146
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Key dispatch for normal mode, keyed by parsed key id. Close keys and
|
|
160
|
+
* the mode-specific search keys are handled in `handleInput` before the
|
|
161
|
+
* table is consulted.
|
|
162
|
+
*/
|
|
163
|
+
private readonly keyActions: Record<string, () => void> = {
|
|
164
|
+
[Key.tab]: () => this.selectRelative(1),
|
|
165
|
+
[Key.shift("tab")]: () => this.selectRelative(-1),
|
|
166
|
+
[Key.down]: () => this.viewer?.scrollBy(-1),
|
|
167
|
+
[Key.up]: () => this.viewer?.scrollBy(1),
|
|
168
|
+
[Key.pageDown]: () => this.viewer?.scrollBy(-this.logRows()),
|
|
169
|
+
[Key.pageUp]: () => this.viewer?.scrollBy(this.logRows()),
|
|
170
|
+
[Key.ctrl("d")]: () => this.viewer?.scrollBy(-this.halfPageRows()),
|
|
171
|
+
[Key.ctrl("u")]: () => this.viewer?.scrollBy(this.halfPageRows()),
|
|
172
|
+
j: () => this.viewer?.scrollBy(-1),
|
|
173
|
+
k: () => this.viewer?.scrollBy(1),
|
|
174
|
+
g: () => this.viewer?.scrollToTop(),
|
|
175
|
+
G: () => this.viewer?.scrollToBottom(),
|
|
176
|
+
s: () => this.viewer?.cycleStreamFilter(),
|
|
177
|
+
f: () => this.viewer?.toggleFollow(),
|
|
178
|
+
w: () => this.viewer?.toggleWrap(),
|
|
179
|
+
"/": () => this.startSearch(),
|
|
180
|
+
[SHORTCUTS_KEY]: () => this.openShortcutsHelp(),
|
|
181
|
+
};
|
|
182
|
+
|
|
147
183
|
handleInput(data: string): void {
|
|
148
184
|
if (this.mode === "search-typing") {
|
|
149
185
|
this.searchInput.handleInput?.(data);
|
|
@@ -151,25 +187,27 @@ export class LogOverlayComponent implements Component {
|
|
|
151
187
|
return;
|
|
152
188
|
}
|
|
153
189
|
|
|
190
|
+
const key = parseKey(data);
|
|
191
|
+
|
|
154
192
|
if (this.mode === "search-active") {
|
|
155
|
-
if (
|
|
193
|
+
if (key === "escape") {
|
|
156
194
|
this.viewer?.clearSearch();
|
|
157
195
|
this.searchInput.setValue("");
|
|
158
196
|
this.mode = "normal";
|
|
159
197
|
this.opts.tui.requestRender();
|
|
160
198
|
return;
|
|
161
199
|
}
|
|
162
|
-
if (
|
|
200
|
+
if (key === "n") {
|
|
163
201
|
this.viewer?.nextMatch();
|
|
164
202
|
this.opts.tui.requestRender();
|
|
165
203
|
return;
|
|
166
204
|
}
|
|
167
|
-
if (
|
|
205
|
+
if (key === "N") {
|
|
168
206
|
this.viewer?.previousMatch();
|
|
169
207
|
this.opts.tui.requestRender();
|
|
170
208
|
return;
|
|
171
209
|
}
|
|
172
|
-
if (
|
|
210
|
+
if (key === "/") {
|
|
173
211
|
this.searchInput.setValue(this.viewer?.getSearchInfo()?.query ?? "");
|
|
174
212
|
this.mode = "search-typing";
|
|
175
213
|
this.opts.tui.requestRender();
|
|
@@ -177,26 +215,12 @@ export class LogOverlayComponent implements Component {
|
|
|
177
215
|
}
|
|
178
216
|
}
|
|
179
217
|
|
|
180
|
-
if (
|
|
181
|
-
matchesKey(data, Key.escape) ||
|
|
182
|
-
matchesKey(data, Key.ctrl("c")) ||
|
|
183
|
-
data === "q" ||
|
|
184
|
-
data === "Q"
|
|
185
|
-
) {
|
|
218
|
+
if (key === "escape" || key === "ctrl+c" || key === "q" || key === "Q") {
|
|
186
219
|
this.close();
|
|
187
220
|
return;
|
|
188
221
|
}
|
|
189
222
|
|
|
190
|
-
|
|
191
|
-
else if (matchesKey(data, Key.shift("tab"))) this.selectRelative(-1);
|
|
192
|
-
else if (matchesKey(data, Key.down) || data === "j")
|
|
193
|
-
this.viewer?.scrollBy(-1);
|
|
194
|
-
else if (matchesKey(data, Key.up) || data === "k") this.viewer?.scrollBy(1);
|
|
195
|
-
else if (data === "g") this.viewer?.scrollToTop();
|
|
196
|
-
else if (data === "G") this.viewer?.scrollToBottom();
|
|
197
|
-
else if (data === "s") this.viewer?.cycleStreamFilter();
|
|
198
|
-
else if (data === "f") this.viewer?.toggleFollow();
|
|
199
|
-
else if (data === "/") this.startSearch();
|
|
223
|
+
this.keyActions[key ?? ""]?.();
|
|
200
224
|
|
|
201
225
|
this.opts.tui.requestRender();
|
|
202
226
|
}
|
|
@@ -223,6 +247,8 @@ export class LogOverlayComponent implements Component {
|
|
|
223
247
|
dispose(): void {
|
|
224
248
|
if (this.disposed) return;
|
|
225
249
|
this.disposed = true;
|
|
250
|
+
this.shortcutsHelp?.();
|
|
251
|
+
this.shortcutsHelp = null;
|
|
226
252
|
if (this.renderTimer) {
|
|
227
253
|
clearTimeout(this.renderTimer);
|
|
228
254
|
this.renderTimer = null;
|
|
@@ -318,6 +344,24 @@ export class LogOverlayComponent implements Component {
|
|
|
318
344
|
this.opts.onClose();
|
|
319
345
|
}
|
|
320
346
|
|
|
347
|
+
/** Rows scrolled by ctrl+u / ctrl+d (half a viewport). */
|
|
348
|
+
private halfPageRows(): number {
|
|
349
|
+
return Math.max(1, Math.floor(this.logRows() / 2));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Open the "?" shortcuts overlay on top of this overlay. While open it
|
|
354
|
+
* captures input; closing it restores focus here. Disposed with the
|
|
355
|
+
* overlay so a background close (auto-hide, kill) cannot leave it behind.
|
|
356
|
+
*/
|
|
357
|
+
private openShortcutsHelp(): void {
|
|
358
|
+
if (this.shortcutsHelp) return;
|
|
359
|
+
this.shortcutsHelp = showShortcutsOverlay(this.opts.tui, {
|
|
360
|
+
theme: this.opts.theme,
|
|
361
|
+
groups: this.shortcutGroups(),
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
321
365
|
private refreshProcesses(preferredProcessId?: string): void {
|
|
322
366
|
this.processes = this.sortProcesses(requestProcessList(this.opts.events));
|
|
323
367
|
if (this.processes.some((process) => process.status === "running")) {
|
|
@@ -604,37 +648,98 @@ export class LogOverlayComponent implements Component {
|
|
|
604
648
|
|
|
605
649
|
const leftPrefix = this.message ?? statusLeft.join(" ");
|
|
606
650
|
const prefix = leftPrefix ? `${leftPrefix} ` : "";
|
|
607
|
-
const keys =
|
|
651
|
+
const keys = renderShortcutHints(
|
|
652
|
+
this.footerHints(),
|
|
653
|
+
this.opts.theme,
|
|
608
654
|
Math.max(1, width - visibleWidth(prefix)),
|
|
609
655
|
);
|
|
610
656
|
return truncateToWidth(`${prefix}${keys}`, width);
|
|
611
657
|
}
|
|
612
658
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
const
|
|
616
|
-
|
|
659
|
+
/** Footer hint list; search mode adds its extra keys up front. */
|
|
660
|
+
private footerHints(): ShortcutHint[] {
|
|
661
|
+
const hints: ShortcutHint[] = [];
|
|
617
662
|
if (this.mode === "search-active") {
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
663
|
+
hints.push(
|
|
664
|
+
{ key: "n", label: "next" },
|
|
665
|
+
{ key: "N", label: "prev" },
|
|
666
|
+
{ key: "/", label: "edit" },
|
|
667
|
+
{ key: "esc", label: "clear" },
|
|
621
668
|
);
|
|
622
669
|
}
|
|
623
|
-
|
|
624
670
|
const streamFilter = this.viewer?.getStreamFilter() ?? "both";
|
|
625
|
-
const
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
671
|
+
const stream = (on: boolean) => (on ? "accent" : "dim");
|
|
672
|
+
const wrapOn = this.viewer?.isWrapEnabled() ?? false;
|
|
673
|
+
hints.push(
|
|
674
|
+
{
|
|
675
|
+
key: "w",
|
|
676
|
+
label: [{ text: "wrap", style: wrapOn ? "accent" : "dim" }],
|
|
677
|
+
},
|
|
678
|
+
{ key: "f", label: "follow" },
|
|
679
|
+
{ key: "/", label: "search" },
|
|
680
|
+
{
|
|
681
|
+
key: "s",
|
|
682
|
+
label: [
|
|
683
|
+
{ text: "stdout", style: stream(streamFilter !== "stderr") },
|
|
684
|
+
{ text: "+", style: "dim" },
|
|
685
|
+
{ text: "stderr", style: stream(streamFilter !== "stdout") },
|
|
686
|
+
],
|
|
687
|
+
},
|
|
688
|
+
{ key: "j/k", label: "scroll" },
|
|
689
|
+
{ key: "pgup/pgdn", label: "page" },
|
|
690
|
+
{ key: "^u/^d", label: "half-page" },
|
|
691
|
+
{ key: "q", label: "close" },
|
|
692
|
+
{ key: "g/G", label: "top/bot" },
|
|
693
|
+
{ key: "tab/shift+tab", label: "switch" },
|
|
694
|
+
);
|
|
695
|
+
return hints;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Groups for the "?" shortcuts overlay. The search group is only present
|
|
700
|
+
* while a search is active; every other key works in both modes.
|
|
701
|
+
*/
|
|
702
|
+
private shortcutGroups(): ShortcutGroup[] {
|
|
703
|
+
const groups: ShortcutGroup[] = [];
|
|
704
|
+
if (this.mode === "search-active") {
|
|
705
|
+
groups.push({
|
|
706
|
+
title: "search",
|
|
707
|
+
rows: [
|
|
708
|
+
{ keys: "n / N", description: "next / previous match" },
|
|
709
|
+
{ keys: "/", description: "edit query" },
|
|
710
|
+
{ keys: "esc", description: "clear search" },
|
|
711
|
+
],
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
groups.push(
|
|
715
|
+
{
|
|
716
|
+
title: "scrolling",
|
|
717
|
+
rows: [
|
|
718
|
+
{ keys: "j / k", description: "line up / down" },
|
|
719
|
+
{ keys: "pgup / pgdn", description: "page up / down" },
|
|
720
|
+
{ keys: "ctrl+u / ctrl+d", description: "half page up / down" },
|
|
721
|
+
{ keys: "g / G", description: "top / bottom" },
|
|
722
|
+
],
|
|
723
|
+
},
|
|
724
|
+
{
|
|
725
|
+
title: "view",
|
|
726
|
+
rows: [
|
|
727
|
+
{ keys: "w", description: "wrap long lines" },
|
|
728
|
+
{ keys: "f", description: "follow newest output" },
|
|
729
|
+
{ keys: "s", description: "stream: stdout + stderr" },
|
|
730
|
+
{ keys: "/", description: "search" },
|
|
731
|
+
],
|
|
732
|
+
},
|
|
733
|
+
{
|
|
734
|
+
title: "tabs",
|
|
735
|
+
rows: [{ keys: "tab / shift+tab", description: "switch process" }],
|
|
736
|
+
},
|
|
737
|
+
{
|
|
738
|
+
title: "general",
|
|
739
|
+
rows: [{ keys: "q", description: "close" }],
|
|
740
|
+
},
|
|
637
741
|
);
|
|
742
|
+
return groups;
|
|
638
743
|
}
|
|
639
744
|
}
|
|
640
745
|
|
|
@@ -655,34 +760,3 @@ function centeredBlock(
|
|
|
655
760
|
lines[row] = truncateToWidth(`${" ".repeat(leftPad)}${content}`, width);
|
|
656
761
|
return lines;
|
|
657
762
|
}
|
|
658
|
-
|
|
659
|
-
function isChangedPayload(
|
|
660
|
-
payload: unknown,
|
|
661
|
-
): payload is ProcessesChangedPayload {
|
|
662
|
-
return (
|
|
663
|
-
isRecord(payload) &&
|
|
664
|
-
(payload.reason === "started" ||
|
|
665
|
-
payload.reason === "ended" ||
|
|
666
|
-
payload.reason === "cleared")
|
|
667
|
-
);
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
function isLogMatchNotification(
|
|
671
|
-
payload: unknown,
|
|
672
|
-
): payload is ProcessProtocolNotificationPayload & {
|
|
673
|
-
kind: "log_match";
|
|
674
|
-
logMatch: NonNullable<ProcessProtocolNotificationPayload["logMatch"]>;
|
|
675
|
-
} {
|
|
676
|
-
if (!isRecord(payload)) return false;
|
|
677
|
-
if (payload.kind !== "log_match") return false;
|
|
678
|
-
if (typeof payload.processId !== "string") return false;
|
|
679
|
-
if (typeof payload.timestamp !== "number") return false;
|
|
680
|
-
const logMatch = payload.logMatch;
|
|
681
|
-
if (!isRecord(logMatch)) return false;
|
|
682
|
-
if (typeof logMatch.pattern !== "string") return false;
|
|
683
|
-
if (typeof logMatch.line !== "string") return false;
|
|
684
|
-
if (logMatch.stream !== "stdout" && logMatch.stream !== "stderr")
|
|
685
|
-
return false;
|
|
686
|
-
if (typeof logMatch.matcherIndex !== "number") return false;
|
|
687
|
-
return true;
|
|
688
|
-
}
|
|
@@ -4,9 +4,7 @@ import {
|
|
|
4
4
|
type LogsChunkPayload,
|
|
5
5
|
type LogsSubscribePayload,
|
|
6
6
|
type LogsUnsubscribePayload,
|
|
7
|
-
} from "
|
|
8
|
-
import { isRecord } from "../../src/utils/is-record";
|
|
9
|
-
|
|
7
|
+
} from "../shared/protocol";
|
|
10
8
|
export type ProcessLogLine = { type: "stdout" | "stderr"; text: string };
|
|
11
9
|
|
|
12
10
|
export interface LogsConnection {
|
|
@@ -29,8 +27,7 @@ export function connectToProcessLogs(
|
|
|
29
27
|
|
|
30
28
|
const disposeChunkListener = events.on(CHANNELS.LOGS_CHUNK, (raw) => {
|
|
31
29
|
if (disposed) return;
|
|
32
|
-
|
|
33
|
-
const chunk = raw;
|
|
30
|
+
const chunk = raw as LogsChunkPayload;
|
|
34
31
|
if (chunk.subscriberId !== subscriberId || chunk.processId !== processId)
|
|
35
32
|
return;
|
|
36
33
|
|
|
@@ -81,21 +78,3 @@ export function connectToProcessLogs(
|
|
|
81
78
|
},
|
|
82
79
|
};
|
|
83
80
|
}
|
|
84
|
-
|
|
85
|
-
function isLogsChunkPayload(payload: unknown): payload is LogsChunkPayload {
|
|
86
|
-
return (
|
|
87
|
-
isRecord(payload) &&
|
|
88
|
-
typeof payload.subscriberId === "string" &&
|
|
89
|
-
typeof payload.processId === "string" &&
|
|
90
|
-
Array.isArray(payload.lines) &&
|
|
91
|
-
payload.lines.every(isProcessLogLine)
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function isProcessLogLine(line: unknown): line is ProcessLogLine {
|
|
96
|
-
return (
|
|
97
|
-
isRecord(line) &&
|
|
98
|
-
(line.type === "stdout" || line.type === "stderr") &&
|
|
99
|
-
typeof line.text === "string"
|
|
100
|
-
);
|
|
101
|
-
}
|