ML-159: add cursor and visual mode to log browser

This commit is contained in:
Claudio Ortolina
2026-05-03 22:33:47 +01:00
parent 3512f812ba
commit 51390974a8
2 changed files with 248 additions and 88 deletions
+221 -76
View File
@@ -26,10 +26,32 @@ interface CoolifyLogsResponse {
logs?: string | string[];
}
/** Minimal theme interface for color application in the LogViewer. */
interface Theme {
fg(color: string, text: string): string;
}
// ── Log Viewer Component ────────────────────────────────────────────────────
class LogViewer {
lines: string[];
/** Absolute index into `this.lines` of the highlighted cursor line.
* `-1` when there are no lines. */
public cursorIndex: number = 0;
/** Whether visual (range-selection) mode is active. */
public visualMode: boolean = false;
/** The line index where visual mode was entered (anchor of the selection
* range). Only meaningful when `visualMode` is true. */
public visualAnchor: number = 0;
/** Callback invoked when the user copies text (Enter in normal mode, y in
* visual mode). The resolved promise value from `ctx.ui.custom` determines
* whether the editor text is set. */
public onCopy?: (text: string) => void;
private scrollOffset: number = 0;
private cachedWidth?: number;
private cachedLines?: string[];
@@ -41,13 +63,19 @@ class LogViewer {
constructor(lines: string[], visibleHeight: number) {
this.lines = lines;
this.visibleHeight = Math.max(1, visibleHeight);
this.clampCursor();
}
/** Replace lines and reset scroll to top. */
/** Replace lines and reset all navigation state. */
updateLines(lines: string[], chromeHeight: number): void {
this.lines = lines;
this.cursorIndex = 0;
this.visualMode = false;
this.visualAnchor = 0;
this.scrollOffset = 0;
this.visibleHeight = Math.min(lines.length, Math.max(10, process.stdout.rows - chromeHeight));
this.clampCursor();
this.clampViewport();
this.invalidate();
}
@@ -59,45 +87,121 @@ class LogViewer {
return this.lines.length;
}
// ── Cursor / viewport helpers ──────────────────────────────────────────
/** Clamp cursorIndex to a valid range. Sets to -1 when there are no lines. */
private clampCursor(): void {
if (this.lines.length === 0) {
this.cursorIndex = -1;
} else {
this.cursorIndex = Math.max(0, Math.min(this.cursorIndex, this.lines.length - 1));
}
}
/** Adjust scrollOffset so cursorIndex stays within the visible viewport. */
private clampViewport(): void {
if (this.lines.length === 0) return;
if (this.cursorIndex < this.scrollOffset) {
this.scrollOffset = this.cursorIndex;
} else if (this.cursorIndex >= this.scrollOffset + this.visibleHeight) {
this.scrollOffset = this.cursorIndex - this.visibleHeight + 1;
}
}
// ── Input handling ─────────────────────────────────────────────────────
handleInput(data: string): void {
if (matchesKey(data, Key.escape) || matchesKey(data, "q")) {
// ── Mode-specific top-level keys ──────────────────────────────────
// Escape in visual mode exits visual mode (clears selection)
if (this.visualMode && matchesKey(data, Key.escape)) {
this.visualMode = false;
this.visualAnchor = 0;
this.invalidate();
return;
}
// Escape or q in normal mode closes the viewer
if (!this.visualMode && (matchesKey(data, Key.escape) || matchesKey(data, "q"))) {
this.onClose?.();
return;
}
// Refresh works in both modes
if (matchesKey(data, "r")) {
this.onRefresh?.();
return;
}
// ── Copy operations ───────────────────────────────────────────────
// Enter in either mode copies the cursor line
if (matchesKey(data, Key.enter)) {
if (this.lines.length > 0 && this.cursorIndex >= 0) {
this.onCopy?.(this.lines[this.cursorIndex]);
}
return;
}
// y in visual mode copies the selected range (oldest-first order)
if (this.visualMode && matchesKey(data, "y")) {
if (this.lines.length > 0) {
const start = Math.min(this.visualAnchor, this.cursorIndex);
const end = Math.max(this.visualAnchor, this.cursorIndex);
// this.lines[0] = newest, so reverse the slice for oldest-first
const text = this.lines.slice(start, end + 1).reverse().join("\n");
this.onCopy?.(text);
}
return;
}
// ── Enter visual mode ─────────────────────────────────────────────
if (!this.visualMode && matchesKey(data, "v") && this.cursorIndex >= 0) {
this.visualMode = true;
this.visualAnchor = this.cursorIndex;
this.invalidate();
return;
}
// ── Movement (cursorIndex moves; viewport auto-clamps) ────────────
let moved = false;
if (matchesKey(data, Key.up) || matchesKey(data, "k")) {
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
this.invalidate();
this.cursorIndex--;
moved = true;
} else if (matchesKey(data, Key.down) || matchesKey(data, "j")) {
const maxOffset = Math.max(0, this.lines.length - this.visibleHeight);
this.scrollOffset = Math.min(maxOffset, this.scrollOffset + 1);
this.invalidate();
this.cursorIndex++;
moved = true;
} else if (matchesKey(data, Key.pageUp)) {
this.scrollOffset = Math.max(0, this.scrollOffset - this.visibleHeight);
this.invalidate();
this.cursorIndex -= this.visibleHeight;
moved = true;
} else if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("f"))) {
const maxOffset = Math.max(0, this.lines.length - this.visibleHeight);
this.scrollOffset = Math.min(maxOffset, this.scrollOffset + this.visibleHeight);
this.invalidate();
this.cursorIndex += this.visibleHeight;
moved = true;
} else if (matchesKey(data, Key.ctrl("b"))) {
this.scrollOffset = Math.max(0, this.scrollOffset - this.visibleHeight);
this.invalidate();
this.cursorIndex -= this.visibleHeight;
moved = true;
} else if (matchesKey(data, Key.home) || matchesKey(data, "g")) {
// "g" twice = go to top; single "g" handled as first press
this.scrollOffset = 0;
this.invalidate();
// "g" once jumps to top (in vim, "gg" or a single "g" both work)
this.cursorIndex = 0;
moved = true;
} else if (matchesKey(data, Key.end) || matchesKey(data, "G")) {
this.scrollOffset = Math.max(0, this.lines.length - this.visibleHeight);
this.cursorIndex = this.lines.length - 1;
moved = true;
}
if (moved) {
this.clampCursor();
this.clampViewport();
this.invalidate();
}
}
render(width: number): string[] {
// ── Rendering ─────────────────────────────────────────────────────────
render(width: number, theme: Theme): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}
@@ -108,17 +212,41 @@ class LogViewer {
this.scrollOffset + this.visibleHeight,
);
// Compute selection range (only when visual mode is active)
const selStart = this.visualMode
? Math.min(this.visualAnchor, this.cursorIndex)
: -1;
const selEnd = this.visualMode
? Math.max(this.visualAnchor, this.cursorIndex)
: -1;
const result: string[] = [];
for (let i = 0; i < visibleLines.length; i++) {
const lineNum = this.scrollOffset + i + 1;
const absIdx = this.scrollOffset + i;
const lineNum = absIdx + 1;
const numStr = String(lineNum).padStart(padLen, " ");
const prefix = ` ${numStr}`;
const content = truncateToWidth(
`${prefix}${visibleLines[i]}`,
width,
"",
);
result.push(content);
// Base prefix widened by 2 chars to accommodate cursor/selection markers
const basePrefix = ` ${numStr}`;
const isCursor = absIdx === this.cursorIndex;
const isSelected = this.visualMode
&& absIdx >= selStart
&& absIdx <= selEnd;
if (isCursor) {
// Cursor takes priority over selection highlight
const line = `${basePrefix}> ${visibleLines[i]}`;
const truncated = truncateToWidth(line, width, "");
result.push(theme.fg("accent", truncated));
} else if (isSelected) {
const line = `${basePrefix}${visibleLines[i]}`;
const truncated = truncateToWidth(line, width, "");
result.push(theme.fg("success", truncated));
} else {
const line = `${basePrefix} ${visibleLines[i]}`;
result.push(truncateToWidth(line, width, ""));
}
}
// Pad to visibleHeight if fewer lines
@@ -271,63 +399,80 @@ export default function prodLogsExtension(pi: ExtensionAPI) {
});
};
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
viewer.onClose = () => done();
viewer.onRefresh = () => doRefresh(tui);
// ── Interactive log browser (returns copied text or null) ─────────
return {
render(width: number) {
// Update scroll info
const pct = viewer.total > 0
? Math.round((viewer.offset / viewer.total) * 100)
: 0;
const from = viewer.offset + 1;
const to = Math.min(
viewer.offset + visibleLogLines(),
viewer.total,
);
// Build result manually
const result: string[] = [];
const border = theme.fg("accent", "─".repeat(width));
result.push(border);
result.push(truncateToWidth(theme.fg("accent", theme.bold(" Production Logs")), width, ""));
const copiedText = await ctx.ui.custom<string | null>(
(tui, theme, _kb, done) => {
viewer.onClose = () => done(null);
viewer.onRefresh = () => doRefresh(tui);
viewer.onCopy = (text: string) => done(text);
const countText = loading
? " Refreshing..."
: ` ${logLinesMut.length} lines`;
result.push(truncateToWidth(theme.fg("muted", countText), width, ""));
result.push(truncateToWidth(theme.fg("dim", "─".repeat(40)), width, ""));
return {
render(width: number) {
const pct = viewer.total > 0
? Math.round((viewer.offset / viewer.total) * 100)
: 0;
const from = viewer.offset + 1;
const to = Math.min(
viewer.offset + visibleLogLines(),
viewer.total,
);
const infoText = ` Lines ${from}-${to} of ${logLinesMut.length} (${pct}%)`;
result.push(truncateToWidth(theme.fg("dim", infoText), width, ""));
const result: string[] = [];
const border = theme.fg("accent", "".repeat(width));
result.push(border);
result.push(truncateToWidth(
theme.fg("accent", theme.bold(" Production Logs")),
width,
"",
));
// Log lines
const logRendered = viewer.render(width);
result.push(...logRendered);
const countText = loading
? " Refreshing..."
: ` ${logLinesMut.length} lines`;
result.push(truncateToWidth(theme.fg("muted", countText), width, ""));
result.push(truncateToWidth(theme.fg("dim", "─".repeat(40)), width, ""));
const helpLine = loading
? " Refreshing..."
: " ↑↓/jk scroll · PgUp/PgDn page · Home/End jump · r refresh · Esc close";
result.push(truncateToWidth(
theme.fg("dim", helpLine),
width,
"",
));
result.push(border);
const infoText = ` Lines ${from}-${to} of ${logLinesMut.length} (${pct}%)`;
result.push(truncateToWidth(theme.fg("dim", infoText), width, ""));
return result;
},
// Log lines
const logRendered = viewer.render(width, theme);
result.push(...logRendered);
invalidate() {
viewer.invalidate();
},
// Mode-aware help text
const helpLine = loading
? " Refreshing..."
: viewer.visualMode
? " VISUAL: j/k extend · y copy · Esc cancel · Enter copy line"
: " ↑↓/jk scroll · PgUp/PgDn page · Home/End jump · v visual · Enter copy line · r refresh · Esc close";
result.push(truncateToWidth(
theme.fg("dim", helpLine),
width,
"",
));
result.push(border);
handleInput(data: string) {
viewer.handleInput(data);
tui.requestRender();
},
};
});
return result;
},
invalidate() {
viewer.invalidate();
},
handleInput(data: string) {
viewer.handleInput(data);
tui.requestRender();
},
};
},
);
// ── Place copied text in editor ────────────────────────────────────
if (copiedText !== null) {
ctx.ui.setEditorText(copiedText);
}
ctx.ui.notify("Log viewer closed", "info");
},