feat(coding-agent/debug): Enabled loading older debug logs from archived files
- Added a DebugLogSource that reads dated log files and feeds the viewer from the debug selector. - Made load-older handling asynchronous to fetch external chunks while preserving cursor and scroll state. - Updated log viewer tests for the new options, external log sources, and async loading paths.
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
- Added interactive debug log viewer with selection, copy, and expand/collapse controls
|
||||
- Added inline filtering and count display to the debug log viewer
|
||||
- Added pid filter toggle and load-older pagination controls to the debug log viewer
|
||||
- Enabled loading older debug logs from archived files in viewer
|
||||
|
||||
### Fixed
|
||||
- Sanitized debug log display to strip control codes, normalize tabs, and trim width
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { InteractiveModeContext } from "../modes/types";
|
||||
import { openPath } from "../utils/open";
|
||||
import { DebugLogViewerComponent } from "./log-viewer";
|
||||
import { generateHeapSnapshotData, type ProfilerSession, startCpuProfile } from "./profiler";
|
||||
import { clearArtifactCache, createReportBundle, getArtifactCacheStats, getLogText } from "./report-bundle";
|
||||
import { clearArtifactCache, createDebugLogSource, createReportBundle, getArtifactCacheStats } from "./report-bundle";
|
||||
import { collectSystemInfo, formatSystemInfo } from "./system-info";
|
||||
|
||||
/** Debug menu options */
|
||||
@@ -273,8 +273,9 @@ export class DebugSelectorComponent extends Container {
|
||||
|
||||
async #handleViewLogs(): Promise<void> {
|
||||
try {
|
||||
const logs = await getLogText();
|
||||
if (!logs) {
|
||||
const logSource = await createDebugLogSource();
|
||||
const logs = await logSource.getInitialText();
|
||||
if (!logs && !logSource.hasOlderLogs()) {
|
||||
this.ctx.showWarning("No log entries found for today.");
|
||||
return;
|
||||
}
|
||||
@@ -285,6 +286,8 @@ export class DebugSelectorComponent extends Container {
|
||||
onExit: () => this.ctx.showDebugSelector(),
|
||||
onStatus: message => this.ctx.showStatus(message, { dim: true }),
|
||||
onError: message => this.ctx.showError(message),
|
||||
onUpdate: () => this.ctx.ui.requestRender(),
|
||||
logSource,
|
||||
});
|
||||
|
||||
this.ctx.editorContainer.clear();
|
||||
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
parseDebugLogPid,
|
||||
parseDebugLogTimestampMs,
|
||||
} from "./log-formatting";
|
||||
import type { DebugLogSource } from "./report-bundle";
|
||||
|
||||
export const SESSION_BOUNDARY_WARNING = "### WARNING - Logs above are older than current session!";
|
||||
export const LOAD_OLDER_LABEL = "### LOAD UP TO 50 OLDER ENTRIES";
|
||||
export const LOAD_OLDER_LABEL = "### MOVE UP TO LOAD MORE...";
|
||||
|
||||
const INITIAL_LOG_CHUNK = 50;
|
||||
const LOAD_OLDER_CHUNK = 50;
|
||||
@@ -22,6 +23,15 @@ type LogEntry = {
|
||||
pid: number | undefined;
|
||||
};
|
||||
|
||||
type CursorToken = { kind: "log"; logIndex: number } | { kind: "load-older" };
|
||||
|
||||
type DebugLogViewerModelOptions = {
|
||||
processStartMs?: number;
|
||||
processPid?: number;
|
||||
hasOlderLogs?: () => boolean;
|
||||
loadOlderLogs?: (limitDays?: number) => Promise<string>;
|
||||
};
|
||||
|
||||
type ViewerRow =
|
||||
| {
|
||||
kind: "warning";
|
||||
@@ -62,8 +72,11 @@ export class DebugLogViewerModel {
|
||||
#loadedStartIndex: number;
|
||||
#processFilterEnabled = false;
|
||||
#processPid: number;
|
||||
#hasOlderLogs?: () => boolean;
|
||||
#loadOlderLogs?: (limitDays?: number) => Promise<string>;
|
||||
|
||||
constructor(logText: string, processStartMs: number = getProcessStartMs(), processPid: number = process.pid) {
|
||||
constructor(logText: string, options: DebugLogViewerModelOptions = {}) {
|
||||
const { processStartMs = getProcessStartMs(), processPid = process.pid, hasOlderLogs, loadOlderLogs } = options;
|
||||
this.#entries = splitLogText(logText).map(rawLine => ({
|
||||
rawLine,
|
||||
timestampMs: parseDebugLogTimestampMs(rawLine),
|
||||
@@ -71,6 +84,8 @@ export class DebugLogViewerModel {
|
||||
}));
|
||||
this.#processStartMs = processStartMs;
|
||||
this.#processPid = processPid;
|
||||
this.#hasOlderLogs = hasOlderLogs;
|
||||
this.#loadOlderLogs = loadOlderLogs;
|
||||
this.#loadedStartIndex = Math.max(0, this.#entries.length - INITIAL_LOG_CHUNK);
|
||||
this.#rows = [];
|
||||
this.#visibleLogIndices = [];
|
||||
@@ -245,28 +260,73 @@ export class DebugLogViewerModel {
|
||||
}
|
||||
|
||||
canLoadOlder(): boolean {
|
||||
return this.#loadedStartIndex > 0;
|
||||
return this.#loadedStartIndex > 0 || this.#hasExternalOlderLogs();
|
||||
}
|
||||
|
||||
loadOlder(additionalCount: number = LOAD_OLDER_CHUNK): void {
|
||||
if (!this.canLoadOlder()) {
|
||||
return;
|
||||
async loadOlder(additionalCount: number = LOAD_OLDER_CHUNK): Promise<boolean> {
|
||||
if (this.#loadedStartIndex > 0) {
|
||||
return this.#loadOlderInMemory(additionalCount);
|
||||
}
|
||||
if (!this.#loadOlderLogs || !this.#hasExternalOlderLogs()) {
|
||||
return false;
|
||||
}
|
||||
const olderText = await this.#loadOlderLogs();
|
||||
if (olderText.length === 0) {
|
||||
if (!this.#hasExternalOlderLogs()) {
|
||||
this.#rebuildRows();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const added = this.prependLogs(olderText);
|
||||
if (added === 0) {
|
||||
if (!this.#hasExternalOlderLogs()) {
|
||||
this.#rebuildRows();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return this.#loadOlderInMemory(additionalCount);
|
||||
}
|
||||
|
||||
prependLogs(logText: string): number {
|
||||
const previousCursor = this.#getCursorToken();
|
||||
const previousAnchorLogIndex = this.#getAnchorLogIndex();
|
||||
const newEntries = splitLogText(logText).map(rawLine => ({
|
||||
rawLine,
|
||||
timestampMs: parseDebugLogTimestampMs(rawLine),
|
||||
pid: parseDebugLogPid(rawLine),
|
||||
}));
|
||||
if (newEntries.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const offset = newEntries.length;
|
||||
this.#entries = [...newEntries, ...this.#entries];
|
||||
this.#loadedStartIndex += offset;
|
||||
this.#expandedLogIndices = new Set([...this.#expandedLogIndices].map(logIndex => logIndex + offset));
|
||||
const adjustedCursor: CursorToken | undefined =
|
||||
previousCursor?.kind === "log" ? { kind: "log", logIndex: previousCursor.logIndex + offset } : previousCursor;
|
||||
const adjustedAnchor = previousAnchorLogIndex === undefined ? undefined : previousAnchorLogIndex + offset;
|
||||
this.#rebuildRows(adjustedCursor, adjustedAnchor);
|
||||
return offset;
|
||||
}
|
||||
|
||||
#loadOlderInMemory(additionalCount: number = LOAD_OLDER_CHUNK): boolean {
|
||||
if (this.#loadedStartIndex === 0) {
|
||||
return false;
|
||||
}
|
||||
const requested = Math.max(1, additionalCount);
|
||||
const nextStart = Math.max(0, this.#loadedStartIndex - requested);
|
||||
if (nextStart === this.#loadedStartIndex) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.#loadedStartIndex = nextStart;
|
||||
this.#rebuildRows();
|
||||
return true;
|
||||
}
|
||||
|
||||
#rebuildRows(): void {
|
||||
const previousCursor = this.#getCursorToken();
|
||||
const previousAnchorLogIndex = this.#getAnchorLogIndex();
|
||||
|
||||
#rebuildRows(
|
||||
previousCursor: CursorToken | undefined = this.#getCursorToken(),
|
||||
previousAnchorLogIndex = this.#getAnchorLogIndex(),
|
||||
): void {
|
||||
const query = this.#filterQuery.toLowerCase();
|
||||
const visible: number[] = [];
|
||||
for (let i = this.#loadedStartIndex; i < this.#entries.length; i++) {
|
||||
@@ -345,6 +405,9 @@ export class DebugLogViewerModel {
|
||||
}
|
||||
|
||||
#hasOlderEntries(query: string): boolean {
|
||||
if (this.#hasExternalOlderLogs()) {
|
||||
return true;
|
||||
}
|
||||
if (this.#loadedStartIndex === 0) {
|
||||
return false;
|
||||
}
|
||||
@@ -357,12 +420,16 @@ export class DebugLogViewerModel {
|
||||
return false;
|
||||
}
|
||||
|
||||
#hasExternalOlderLogs(): boolean {
|
||||
return this.#hasOlderLogs?.() ?? false;
|
||||
}
|
||||
|
||||
#getCursorRow(): ViewerRow | undefined {
|
||||
const rowIndex = this.cursorRowIndex;
|
||||
return rowIndex === undefined ? undefined : this.#rows[rowIndex];
|
||||
}
|
||||
|
||||
#getCursorToken(): { kind: "log"; logIndex: number } | { kind: "load-older" } | undefined {
|
||||
#getCursorToken(): CursorToken | undefined {
|
||||
const row = this.#getCursorRow();
|
||||
if (!row) {
|
||||
return undefined;
|
||||
@@ -393,6 +460,9 @@ interface DebugLogViewerComponentOptions {
|
||||
onStatus?: (message: string) => void;
|
||||
onError?: (message: string) => void;
|
||||
processStartMs?: number;
|
||||
processPid?: number;
|
||||
logSource?: DebugLogSource;
|
||||
onUpdate?: () => void;
|
||||
}
|
||||
|
||||
export class DebugLogViewerComponent implements Component {
|
||||
@@ -401,16 +471,26 @@ export class DebugLogViewerComponent implements Component {
|
||||
#onExit: () => void;
|
||||
#onStatus?: (message: string) => void;
|
||||
#onError?: (message: string) => void;
|
||||
#onUpdate?: () => void;
|
||||
#logSource?: DebugLogSource;
|
||||
#lastRenderWidth = 80;
|
||||
#scrollRowOffset = 0;
|
||||
#statusMessage: string | undefined;
|
||||
#loadingOlder = false;
|
||||
|
||||
constructor(options: DebugLogViewerComponentOptions) {
|
||||
this.#model = new DebugLogViewerModel(options.logs, options.processStartMs);
|
||||
this.#logSource = options.logSource;
|
||||
this.#model = new DebugLogViewerModel(options.logs, {
|
||||
processStartMs: options.processStartMs,
|
||||
processPid: options.processPid,
|
||||
hasOlderLogs: this.#logSource?.hasOlderLogs.bind(this.#logSource),
|
||||
loadOlderLogs: this.#logSource?.loadOlderLogs.bind(this.#logSource),
|
||||
});
|
||||
this.#terminalRows = options.terminalRows;
|
||||
this.#onExit = options.onExit;
|
||||
this.#onStatus = options.onStatus;
|
||||
this.#onError = options.onError;
|
||||
this.#onUpdate = options.onUpdate;
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
@@ -440,27 +520,21 @@ export class DebugLogViewerComponent implements Component {
|
||||
|
||||
if (matchesKey(keyData, "ctrl+o")) {
|
||||
this.#statusMessage = undefined;
|
||||
this.#model.loadOlder(this.#bodyHeight() + 1);
|
||||
this.#ensureCursorVisible();
|
||||
void this.#handleLoadOlder(this.#bodyHeight() + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(keyData, "enter") || matchesKey(keyData, "return")) {
|
||||
if (this.#model.cursorRowKind === "load-older") {
|
||||
this.#statusMessage = undefined;
|
||||
this.#model.loadOlder();
|
||||
this.#ensureCursorVisible();
|
||||
void this.#handleLoadOlder();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(keyData, "shift+up")) {
|
||||
this.#statusMessage = undefined;
|
||||
if (this.#maybeLoadOlderOnUp(true)) {
|
||||
return;
|
||||
}
|
||||
this.#model.moveCursor(-1, true);
|
||||
this.#ensureCursorVisible();
|
||||
void this.#handleMoveUp(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -473,11 +547,7 @@ export class DebugLogViewerComponent implements Component {
|
||||
|
||||
if (matchesKey(keyData, "up")) {
|
||||
this.#statusMessage = undefined;
|
||||
if (this.#maybeLoadOlderOnUp(false)) {
|
||||
return;
|
||||
}
|
||||
this.#model.moveCursor(-1, false);
|
||||
this.#ensureCursorVisible();
|
||||
void this.#handleMoveUp(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -491,8 +561,7 @@ export class DebugLogViewerComponent implements Component {
|
||||
if (matchesKey(keyData, "right")) {
|
||||
this.#statusMessage = undefined;
|
||||
if (this.#model.cursorRowKind === "load-older") {
|
||||
this.#model.loadOlder();
|
||||
this.#ensureCursorVisible();
|
||||
void this.#handleLoadOlder();
|
||||
return;
|
||||
}
|
||||
this.#model.expandSelected();
|
||||
@@ -541,7 +610,6 @@ export class DebugLogViewerComponent implements Component {
|
||||
|
||||
return [
|
||||
this.#frameTop(innerWidth),
|
||||
this.#frameSeparator(innerWidth),
|
||||
this.#frameLine(this.#summaryText(), innerWidth),
|
||||
this.#frameSeparator(innerWidth),
|
||||
this.#frameLine(this.#filterText(), innerWidth),
|
||||
@@ -557,7 +625,7 @@ export class DebugLogViewerComponent implements Component {
|
||||
}
|
||||
|
||||
#controlsText(): string {
|
||||
return "Up/Down: move Shift+Up/Down: select range Left/Right: collapse/expand Ctrl+A: select all Ctrl+P: pid filter Ctrl+O: load older Ctrl+C: copy Esc: back";
|
||||
return "Esc: back Ctrl+C: copy Up/Down: move Shift+Up/Down: select range Left/Right: collapse/expand Ctrl+A: select all Ctrl+O: load older Ctrl+P: pid filter";
|
||||
}
|
||||
|
||||
#filterText(): string {
|
||||
@@ -581,21 +649,72 @@ export class DebugLogViewerComponent implements Component {
|
||||
return Math.max(3, this.#terminalRows - 8);
|
||||
}
|
||||
|
||||
#maybeLoadOlderOnUp(extendSelection: boolean): boolean {
|
||||
if (this.#model.cursorRowKind === "load-older") {
|
||||
this.#model.loadOlder();
|
||||
async #handleLoadOlder(additionalCount: number = LOAD_OLDER_CHUNK): Promise<void> {
|
||||
const loaded = await this.#loadOlder(additionalCount);
|
||||
if (loaded) {
|
||||
this.#ensureCursorVisible();
|
||||
return true;
|
||||
this.#onUpdate?.();
|
||||
}
|
||||
}
|
||||
|
||||
async #handleMoveUp(extendSelection: boolean): Promise<void> {
|
||||
if (this.#model.cursorRowKind === "load-older") {
|
||||
const loaded = await this.#loadOlder(LOAD_OLDER_CHUNK);
|
||||
if (loaded) {
|
||||
this.#ensureCursorVisible();
|
||||
this.#onUpdate?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.#model.canLoadOlder() || !this.#model.isCursorAtFirstSelectableRow()) {
|
||||
return false;
|
||||
if (this.#model.canLoadOlder() && this.#model.isCursorAtFirstSelectableRow()) {
|
||||
const loaded = await this.#loadOlder(LOAD_OLDER_CHUNK);
|
||||
if (loaded) {
|
||||
this.#model.moveCursor(-1, extendSelection);
|
||||
this.#ensureCursorVisible();
|
||||
this.#onUpdate?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.#model.loadOlder();
|
||||
this.#model.moveCursor(-1, extendSelection);
|
||||
this.#ensureCursorVisible();
|
||||
return true;
|
||||
this.#onUpdate?.();
|
||||
}
|
||||
|
||||
async #loadOlder(additionalCount: number): Promise<boolean> {
|
||||
if (this.#loadingOlder || !this.#model.canLoadOlder()) {
|
||||
return false;
|
||||
}
|
||||
this.#loadingOlder = true;
|
||||
const previousCursorRowIndex = this.#model.cursorRowIndex;
|
||||
const previousScrollOffset = this.#scrollRowOffset;
|
||||
try {
|
||||
const didLoad = await this.#model.loadOlder(additionalCount);
|
||||
if (didLoad) {
|
||||
this.#preserveScrollPosition(previousCursorRowIndex, previousScrollOffset);
|
||||
}
|
||||
return didLoad;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.#statusMessage = `Load older failed: ${message}`;
|
||||
this.#onError?.(`Failed to load older logs: ${message}`);
|
||||
this.#onUpdate?.();
|
||||
return false;
|
||||
} finally {
|
||||
this.#loadingOlder = false;
|
||||
}
|
||||
}
|
||||
|
||||
#preserveScrollPosition(previousCursorRowIndex: number | undefined, previousScrollOffset: number): void {
|
||||
const cursorRowIndex = this.#model.cursorRowIndex;
|
||||
if (previousCursorRowIndex === undefined || cursorRowIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
const delta = cursorRowIndex - previousCursorRowIndex;
|
||||
const nextOffset = previousScrollOffset + delta;
|
||||
const maxOffset = Math.max(0, this.#model.rows.length - this.#bodyHeight());
|
||||
this.#scrollRowOffset = Math.max(0, Math.min(maxOffset, nextOffset));
|
||||
}
|
||||
|
||||
#renderRows(innerWidth: number): Array<{ lines: string[]; rowIndex: number }> {
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import type { WorkProfile } from "@oh-my-pi/pi-natives";
|
||||
import { isEnoent } from "@oh-my-pi/pi-utils";
|
||||
import { getLogPath, getReportsDir } from "@oh-my-pi/pi-utils/dirs";
|
||||
import { APP_NAME, getLogPath, getLogsDir, getReportsDir } from "@oh-my-pi/pi-utils/dirs";
|
||||
import type { CpuProfile, HeapSnapshot } from "./profiler";
|
||||
import { collectSystemInfo, sanitizeEnv } from "./system-info";
|
||||
|
||||
@@ -41,6 +41,12 @@ export interface ReportBundleResult {
|
||||
files: string[];
|
||||
}
|
||||
|
||||
export interface DebugLogSource {
|
||||
getInitialText(): Promise<string>;
|
||||
hasOlderLogs(): boolean;
|
||||
loadOlderLogs(limitDays?: number): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a debug report bundle.
|
||||
*
|
||||
@@ -220,6 +226,73 @@ export async function getLogText(): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
const LOG_FILE_PATTERN = new RegExp(`^${APP_NAME}\\.(\\d{4}-\\d{2}-\\d{2})\\.log$`);
|
||||
|
||||
export async function createDebugLogSource(): Promise<DebugLogSource> {
|
||||
const logsDir = getLogsDir();
|
||||
const todayPath = getLogPath();
|
||||
const todayName = path.basename(todayPath);
|
||||
let olderFiles: string[] = [];
|
||||
try {
|
||||
const entries = await fs.readdir(logsDir, { withFileTypes: true });
|
||||
const datedFiles = entries
|
||||
.filter(entry => entry.isFile())
|
||||
.map(entry => {
|
||||
const match = LOG_FILE_PATTERN.exec(entry.name);
|
||||
return match ? { name: entry.name, date: match[1] } : undefined;
|
||||
})
|
||||
.filter((entry): entry is { name: string; date: string } => entry !== undefined)
|
||||
.filter(entry => entry.name !== todayName)
|
||||
.sort((a, b) => b.date.localeCompare(a.date));
|
||||
olderFiles = datedFiles.map(entry => entry.name);
|
||||
} catch {
|
||||
olderFiles = [];
|
||||
}
|
||||
|
||||
let cursor = 0;
|
||||
|
||||
const getInitialText = async (): Promise<string> => {
|
||||
try {
|
||||
return await Bun.file(todayPath).text();
|
||||
} catch (err) {
|
||||
if (isEnoent(err)) return "";
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const hasOlderLogs = (): boolean => cursor < olderFiles.length;
|
||||
|
||||
const loadOlderLogs = async (limitDays: number = 1): Promise<string> => {
|
||||
if (!hasOlderLogs()) {
|
||||
return "";
|
||||
}
|
||||
const count = Math.max(1, limitDays);
|
||||
const slice = olderFiles.slice(cursor, cursor + count);
|
||||
cursor += slice.length;
|
||||
const chunks: string[] = [];
|
||||
for (const filename of slice.reverse()) {
|
||||
const filePath = path.join(logsDir, filename);
|
||||
try {
|
||||
const content = await Bun.file(filePath).text();
|
||||
if (content.length > 0) {
|
||||
chunks.push(content);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isEnoent(err)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
return chunks.filter(chunk => chunk.length > 0).join("\n");
|
||||
};
|
||||
|
||||
return {
|
||||
getInitialText,
|
||||
hasOlderLogs,
|
||||
loadOlderLogs,
|
||||
};
|
||||
}
|
||||
|
||||
/** Calculate total size of artifact cache */
|
||||
export async function getArtifactCacheStats(
|
||||
sessionsDir: string,
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("DebugLogViewerModel", () => {
|
||||
|
||||
it("defaults cursor to the newest log entry", () => {
|
||||
const logs = ["alpha", "beta", "gamma"].join("\n");
|
||||
const model = new DebugLogViewerModel(logs, Date.now());
|
||||
const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() });
|
||||
expect(model.cursorLogIndex).toBe(2);
|
||||
});
|
||||
it("inserts session boundary warning between older and current-session logs", () => {
|
||||
@@ -33,7 +33,7 @@ describe("DebugLogViewerModel", () => {
|
||||
'{"timestamp":"2026-02-14T12:00:05.000Z","level":"info","message":"current"}',
|
||||
].join("\n");
|
||||
|
||||
const model = new DebugLogViewerModel(logs, processStartMs);
|
||||
const model = new DebugLogViewerModel(logs, { processStartMs });
|
||||
const rowKinds = model.rows.map(row => describeRow(row as { kind: string; logIndex?: number }));
|
||||
|
||||
expect(rowKinds).toEqual(["log:0", "log:1", SESSION_BOUNDARY_WARNING, "log:2"]);
|
||||
@@ -41,7 +41,7 @@ describe("DebugLogViewerModel", () => {
|
||||
|
||||
it("filters logs with case-insensitive substring matching", () => {
|
||||
const logs = ["Alpha", "beta", "Gamma", "BETTER"].join("\n");
|
||||
const model = new DebugLogViewerModel(logs, Date.now());
|
||||
const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() });
|
||||
|
||||
model.setFilterQuery("be");
|
||||
const rowKinds = model.rows.map(row => describeRow(row as { kind: string; logIndex?: number }));
|
||||
@@ -55,7 +55,7 @@ describe("DebugLogViewerModel", () => {
|
||||
'{"pid":84,"level":"info","message":"beta"}',
|
||||
'{"level":"info","message":"missing"}',
|
||||
].join("\n");
|
||||
const model = new DebugLogViewerModel(logs, Date.now(), 42);
|
||||
const model = new DebugLogViewerModel(logs, { processStartMs: Date.now(), processPid: 42 });
|
||||
|
||||
expect(model.visibleLogCount).toBe(3);
|
||||
model.toggleProcessFilter();
|
||||
@@ -65,26 +65,26 @@ describe("DebugLogViewerModel", () => {
|
||||
|
||||
it("selects all visible log rows", () => {
|
||||
const logs = ["alpha", "beta", "gamma"].join("\n");
|
||||
const model = new DebugLogViewerModel(logs, Date.now());
|
||||
const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() });
|
||||
|
||||
model.selectAllVisible();
|
||||
expect(model.getSelectedLogIndices()).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("progressively loads older entries in chunks", () => {
|
||||
it("progressively loads older entries in chunks", async () => {
|
||||
const logs = Array.from({ length: 120 }, (_, index) => `log-${index}`).join("\n");
|
||||
const model = new DebugLogViewerModel(logs, Date.now());
|
||||
const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() });
|
||||
|
||||
expect(model.visibleLogCount).toBe(50);
|
||||
const initialKinds = model.rows.slice(0, 2).map(row => describeRow(row as { kind: string; logIndex?: number }));
|
||||
expect(initialKinds).toEqual([LOAD_OLDER_LABEL, "log:70"]);
|
||||
|
||||
model.loadOlder(5);
|
||||
await model.loadOlder(5);
|
||||
expect(model.visibleLogCount).toBe(55);
|
||||
const expandedKinds = model.rows.slice(0, 2).map(row => describeRow(row as { kind: string; logIndex?: number }));
|
||||
expect(expandedKinds).toEqual([LOAD_OLDER_LABEL, "log:65"]);
|
||||
|
||||
model.loadOlder(50);
|
||||
await model.loadOlder(50);
|
||||
expect(model.visibleLogCount).toBe(105);
|
||||
const expandedAgainKinds = model.rows
|
||||
.slice(0, 2)
|
||||
@@ -92,9 +92,27 @@ describe("DebugLogViewerModel", () => {
|
||||
expect(expandedAgainKinds).toEqual([LOAD_OLDER_LABEL, "log:15"]);
|
||||
});
|
||||
|
||||
it("loads older entries from external sources while keeping cursor stable", async () => {
|
||||
const logs = ["new-1", "new-2", "new-3"].join("\n");
|
||||
let hasOlder = true;
|
||||
const model = new DebugLogViewerModel(logs, {
|
||||
processStartMs: Date.now(),
|
||||
hasOlderLogs: () => hasOlder,
|
||||
loadOlderLogs: async () => {
|
||||
hasOlder = false;
|
||||
return ["old-1", "old-2"].join("\n");
|
||||
},
|
||||
});
|
||||
|
||||
expect(model.rows[0]?.kind).toBe("load-older");
|
||||
await model.loadOlder(1);
|
||||
expect(model.getRawLine(model.cursorLogIndex ?? 0)).toBe("new-3");
|
||||
expect(model.rows[0]?.kind).toBe("load-older");
|
||||
});
|
||||
|
||||
it("clamps cursor when filtered list shrinks", () => {
|
||||
const logs = ["alpha", "beta", "gamma"].join("\n");
|
||||
const model = new DebugLogViewerModel(logs, Date.now());
|
||||
const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() });
|
||||
|
||||
model.moveCursor(2, false);
|
||||
expect(model.cursorLogIndex).toBe(2);
|
||||
@@ -106,7 +124,7 @@ describe("DebugLogViewerModel", () => {
|
||||
|
||||
it("resets selection anchor when filtered view drops the anchor log", () => {
|
||||
const logs = ["alpha", "beta", "gamma", "delta"].join("\n");
|
||||
const model = new DebugLogViewerModel(logs, Date.now());
|
||||
const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() });
|
||||
|
||||
model.moveCursor(-999, false);
|
||||
model.moveCursor(2, true);
|
||||
@@ -123,7 +141,7 @@ describe("DebugLogViewerModel", () => {
|
||||
'{"timestamp":"2026-02-14T12:00:05.000Z","level":"info","message":"current"}',
|
||||
'{"timestamp":"2026-02-14T12:00:10.000Z","level":"info","message":"current-2"}',
|
||||
].join("\n");
|
||||
const model = new DebugLogViewerModel(logs, processStartMs);
|
||||
const model = new DebugLogViewerModel(logs, { processStartMs });
|
||||
|
||||
model.setFilterQuery("old");
|
||||
expect(model.rows.map(row => row.kind)).toEqual(["log"]);
|
||||
@@ -142,7 +160,7 @@ describe("DebugLogViewerModel", () => {
|
||||
|
||||
it("copies only selected visible entries", () => {
|
||||
const logs = ["alpha", "bar", "baz"].join("\n");
|
||||
const model = new DebugLogViewerModel(logs, Date.now());
|
||||
const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() });
|
||||
|
||||
model.setFilterQuery("ba");
|
||||
model.moveCursor(-999, false);
|
||||
@@ -153,7 +171,7 @@ describe("DebugLogViewerModel", () => {
|
||||
|
||||
it("supports shift-range selection and reset on plain movement", () => {
|
||||
const logs = ["a", "b", "c", "d"].join("\n");
|
||||
const model = new DebugLogViewerModel(logs, Date.now());
|
||||
const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() });
|
||||
|
||||
model.moveCursor(-999, false);
|
||||
model.moveCursor(1, true);
|
||||
@@ -166,7 +184,7 @@ describe("DebugLogViewerModel", () => {
|
||||
|
||||
it("expands and collapses all selected rows", () => {
|
||||
const logs = ["a", "b", "c"].join("\n");
|
||||
const model = new DebugLogViewerModel(logs, Date.now());
|
||||
const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() });
|
||||
|
||||
model.moveCursor(-999, false);
|
||||
model.moveCursor(1, true);
|
||||
|
||||
Reference in New Issue
Block a user