fix(FN-2400): harden dashboard TUI log viewport budgeting
- Rework logs viewport calculation to budget by rendered rows and keep content above footer hints - Add zero-row safety handling for short terminals with a dedicated expansion hint instead of overlapping output - Cap wrapped log rendering by remaining row budget so long messages cannot overflow into footer space - Add regression tests for footer-safe list windowing and wrapped-line truncation, plus a patch changeset for @runfusion/fusion
This commit is contained in:
@@ -748,7 +748,7 @@ describe("DashboardTUI Logs viewport window", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
tui = createTestTUI();
|
||||
tui._setTerminalSize(80, 14); // maxRows = 5 in logs list
|
||||
tui._setTerminalSize(80, 14); // rowBudget = 4 in logs list (footer-safe)
|
||||
(tui as any).activeSection = "logs";
|
||||
|
||||
stdoutWriteSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
@@ -782,7 +782,8 @@ describe("DashboardTUI Logs viewport window", () => {
|
||||
const output = renderLogs();
|
||||
|
||||
expect(output).toContain("Entry 5");
|
||||
expect(output).toContain("Entry 9");
|
||||
expect(output).toContain("Entry 8");
|
||||
expect(output).not.toContain("Entry 9");
|
||||
expect(output).not.toContain("Entry 12");
|
||||
expect((tui as any).logsViewportStart).toBe(4);
|
||||
});
|
||||
@@ -797,7 +798,60 @@ describe("DashboardTUI Logs viewport window", () => {
|
||||
expect((tui as any).selectedLogIndex).toBe(11);
|
||||
output = renderLogs();
|
||||
expect(output).toContain("Entry 12");
|
||||
expect((tui as any).logsViewportStart).toBe(7);
|
||||
expect((tui as any).logsViewportStart).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DashboardTUI Logs footer-safe row budgeting", () => {
|
||||
let tui: DashboardTUI & {
|
||||
_stdout: string[];
|
||||
_setTerminalSize: (cols: number, rows: number) => void;
|
||||
};
|
||||
let stdoutWriteSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
const stripAnsi = (output: string): string => output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
|
||||
|
||||
beforeEach(() => {
|
||||
tui = createTestTUI();
|
||||
(tui as any).activeSection = "logs";
|
||||
(tui as any).isRunning = true;
|
||||
stdoutWriteSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stdoutWriteSpy.mockRestore();
|
||||
Object.defineProperty(process.stdout, "columns", { value: 80, writable: true });
|
||||
Object.defineProperty(process.stdout, "rows", { value: 24, writable: true });
|
||||
});
|
||||
|
||||
it("shows a short-terminal hint and suppresses log lines when no safe body rows exist", () => {
|
||||
tui._setTerminalSize(80, 10); // rowBudget = 0 for log body
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
tui.log(`Entry ${i}`);
|
||||
}
|
||||
|
||||
simulateKeypress(tui, "End");
|
||||
stdoutWriteSpy.mockClear();
|
||||
(tui as any).renderLogsSection();
|
||||
const rendered = stripAnsi(stdoutWriteSpy.mock.calls.map(([chunk]) => String(chunk)).join(""));
|
||||
|
||||
expect(rendered).toContain("Terminal too short — expand terminal to view logs.");
|
||||
expect(rendered).not.toContain("Entry 6");
|
||||
expect(rendered).not.toContain("Entry 1");
|
||||
});
|
||||
|
||||
it("caps wrapped output rows so a single long message cannot overflow footer budget", () => {
|
||||
tui._setTerminalSize(36, 11); // rowBudget = 1 for log body
|
||||
tui.log("aa bb cc dd ee ff gg hh ii jj kk ll mm nn");
|
||||
simulateKeypress(tui, "w");
|
||||
|
||||
stdoutWriteSpy.mockClear();
|
||||
(tui as any).renderLogsSection();
|
||||
const rendered = stripAnsi(stdoutWriteSpy.mock.calls.map(([chunk]) => String(chunk)).join(""));
|
||||
|
||||
// First wrapped segment renders, but continuation rows are suppressed by budget.
|
||||
expect(rendered).toContain("aa");
|
||||
expect(rendered).not.toContain("nn");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -779,32 +779,82 @@ export class DashboardTUI {
|
||||
}
|
||||
}
|
||||
|
||||
private getLogViewportStart(totalEntries: number, maxRows: number): number {
|
||||
if (totalEntries <= 0) {
|
||||
private getFooterTopRow(totalRows: number): number {
|
||||
return Math.max(1, totalRows - 2);
|
||||
}
|
||||
|
||||
private getLogsListRowBudget(totalRows: number): number {
|
||||
// In list mode, first log body line starts at row 8:
|
||||
// 1-2: header, 3: spacer, 4: "LOGS", 5: ring buffer, 6: wrap mode, 7: spacer
|
||||
// Footer hint is rendered at rows-2, so log body must stay above that row.
|
||||
const firstLogBodyRow = 8;
|
||||
const footerTopRow = this.getFooterTopRow(totalRows);
|
||||
return Math.max(0, footerTopRow - firstLogBodyRow);
|
||||
}
|
||||
|
||||
private getLogEntryRowCount(entry: LogEntry, cols: number): number {
|
||||
if (!this.logsWrapEnabled) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const prefixLen = 30; // timestamp + level + prefix overhead
|
||||
const availableWidth = Math.max(8, cols - prefixLen);
|
||||
return Math.max(1, this.wrapText(entry.message, availableWidth).length);
|
||||
}
|
||||
|
||||
private getLogsViewportWindow(entries: LogEntry[], rowBudget: number, cols: number): { start: number; end: number } {
|
||||
if (entries.length === 0) {
|
||||
this.logsViewportStart = 0;
|
||||
return 0;
|
||||
return { start: 0, end: 0 };
|
||||
}
|
||||
|
||||
const maxStart = Math.max(0, totalEntries - maxRows);
|
||||
let start = Math.min(this.logsViewportStart, maxStart);
|
||||
const maxStart = Math.max(0, entries.length - 1);
|
||||
const safeSelectedIndex = Math.max(0, Math.min(this.selectedLogIndex, entries.length - 1));
|
||||
let start = Math.max(0, Math.min(this.logsViewportStart, maxStart));
|
||||
|
||||
if (this.selectedLogIndex < start) {
|
||||
start = this.selectedLogIndex;
|
||||
} else if (this.selectedLogIndex >= start + maxRows) {
|
||||
start = this.selectedLogIndex - maxRows + 1;
|
||||
if (safeSelectedIndex < start) {
|
||||
start = safeSelectedIndex;
|
||||
}
|
||||
|
||||
this.logsViewportStart = Math.max(0, Math.min(start, maxStart));
|
||||
return this.logsViewportStart;
|
||||
const measureWindow = (startIndex: number): { end: number } => {
|
||||
let rowsUsed = 0;
|
||||
let end = startIndex;
|
||||
|
||||
while (end < entries.length) {
|
||||
const entryRows = this.getLogEntryRowCount(entries[end], cols);
|
||||
|
||||
// Always include at least one entry, even if it needs more rows than budget.
|
||||
if (end > startIndex && rowsUsed + entryRows > rowBudget) {
|
||||
break;
|
||||
}
|
||||
|
||||
rowsUsed += entryRows;
|
||||
end++;
|
||||
|
||||
if (rowsUsed >= rowBudget) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { end };
|
||||
};
|
||||
|
||||
let window = measureWindow(start);
|
||||
|
||||
while (safeSelectedIndex >= window.end && start < maxStart) {
|
||||
start++;
|
||||
window = measureWindow(start);
|
||||
}
|
||||
|
||||
this.logsViewportStart = start;
|
||||
return { start, end: window.end };
|
||||
}
|
||||
|
||||
private renderLogsSection(): void {
|
||||
const cols = process.stdout.columns || 80;
|
||||
const rows = process.stdout.rows ?? 38;
|
||||
const entries = this.logBuffer.getAll();
|
||||
// Clamp to minimum 1 row to handle very small terminals.
|
||||
// Reserve 9 rows for fixed UI chrome (header, section labels/spacers, footer)
|
||||
// so content never overlaps the footer on short terminals.
|
||||
const maxRows = Math.max(1, (process.stdout.rows ?? 38) - 9);
|
||||
const rowBudget = this.getLogsListRowBudget(rows);
|
||||
|
||||
process.stdout.write(colorize("\n LOGS\n", "bold"));
|
||||
process.stdout.write(colorize(` Ring buffer: ${this.logBuffer.total}/${MAX_LOG_ENTRIES} entries\n`, "dim"));
|
||||
@@ -831,13 +881,19 @@ export class DashboardTUI {
|
||||
const modeIndicator = this.logsWrapEnabled ? colorize(" [w] wrap on", "dim") : colorize(" [w] wrap off", "dim");
|
||||
process.stdout.write(modeIndicator + "\n\n");
|
||||
|
||||
// Calculate viewport window from selection, so every ring-buffer entry remains reachable.
|
||||
const startIndex = this.getLogViewportStart(entries.length, maxRows);
|
||||
const visibleEntries = entries.slice(startIndex, startIndex + maxRows);
|
||||
if (rowBudget === 0) {
|
||||
process.stdout.write(colorize(" Terminal too short — expand terminal to view logs.\n", "dim"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate viewport window from selection, keeping entry-based navigation
|
||||
// while budgeting by printable rows so wrapped content cannot reach footer.
|
||||
const { start: startIndex, end: endIndex } = this.getLogsViewportWindow(entries, rowBudget, cols);
|
||||
const visibleEntries = entries.slice(startIndex, endIndex);
|
||||
const visibleReversed = [...visibleEntries].reverse();
|
||||
|
||||
// Map selected index to display index (for highlighting in newest-first list)
|
||||
const selectedDisplayIndex = safeSelectedIndex >= startIndex && safeSelectedIndex < startIndex + visibleEntries.length
|
||||
const selectedDisplayIndex = safeSelectedIndex >= startIndex && safeSelectedIndex < endIndex
|
||||
? visibleEntries.length - 1 - (safeSelectedIndex - startIndex)
|
||||
: -1;
|
||||
|
||||
@@ -845,7 +901,9 @@ export class DashboardTUI {
|
||||
const prefixLen = 30; // timestamp + level + prefix overhead
|
||||
const availableWidth = Math.max(8, cols - prefixLen);
|
||||
|
||||
for (let displayIdx = 0; displayIdx < visibleReversed.length; displayIdx++) {
|
||||
let remainingRows = rowBudget;
|
||||
|
||||
for (let displayIdx = 0; displayIdx < visibleReversed.length && remainingRows > 0; displayIdx++) {
|
||||
const entry = visibleReversed[displayIdx];
|
||||
const isSelected = displayIdx === selectedDisplayIndex;
|
||||
|
||||
@@ -858,15 +916,21 @@ export class DashboardTUI {
|
||||
: colorize("✓", "brightGreen");
|
||||
|
||||
if (this.logsWrapEnabled) {
|
||||
// Wrapped mode: wrap message to available width
|
||||
// Wrapped mode: wrap message to available width and cap printed rows to footer-safe budget.
|
||||
const wrappedLines = this.wrapText(entry.message, availableWidth);
|
||||
const lineBudget = Math.max(1, remainingRows);
|
||||
const renderedLines = wrappedLines.slice(0, lineBudget);
|
||||
|
||||
// First line includes prefix
|
||||
const firstLine = `${selector}${ts} ${levelChar} ${prefix ? prefix + " " : ""}${wrappedLines[0]}`;
|
||||
const firstLine = `${selector}${ts} ${levelChar} ${prefix ? prefix + " " : ""}${renderedLines[0] ?? ""}`;
|
||||
process.stdout.write(visibleTruncate(firstLine, cols - 1) + "\n");
|
||||
remainingRows--;
|
||||
|
||||
// Continuation lines (indented)
|
||||
for (let i = 1; i < wrappedLines.length; i++) {
|
||||
const continuation = ` ${wrappedLines[i]}`;
|
||||
for (let i = 1; i < renderedLines.length && remainingRows > 0; i++) {
|
||||
const continuation = ` ${renderedLines[i]}`;
|
||||
process.stdout.write(visibleTruncate(continuation, cols - 1) + "\n");
|
||||
remainingRows--;
|
||||
}
|
||||
} else {
|
||||
// Single-line mode: truncate to available width
|
||||
@@ -874,6 +938,7 @@ export class DashboardTUI {
|
||||
const message = visibleTruncate(entry.message, messageWidth);
|
||||
const line = `${selector}${ts} ${levelChar} ${prefix ? prefix + " " : ""}${message}`;
|
||||
process.stdout.write(visibleTruncate(line, cols - 1) + "\n");
|
||||
remainingRows--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2333,7 +2333,8 @@ describe("SettingsModal", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
expect(screen.getAllByText("Notifications").length).toBeGreaterThanOrEqual(1);
|
||||
const notificationsLabels = await screen.findAllByText("Notifications");
|
||||
expect(notificationsLabels.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("shows ntfy enable checkbox in Notifications section", async () => {
|
||||
|
||||
@@ -4578,8 +4578,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
*
|
||||
* Strategy (in priority order):
|
||||
* 1. **Branch merge-base** — Prefer the live merge-base between HEAD and
|
||||
* `origin/{baseBranch}` (or bare `{baseBranch}`). This stays correct as
|
||||
* the base branch advances and is merged into the feature branch.
|
||||
* the local `{baseBranch}` ref (falling back to `origin/{baseBranch}`
|
||||
* when the local ref is missing). The local ref reflects the worktree's
|
||||
* actual fork point regardless of whether merges have been pushed; using
|
||||
* `origin/{baseBranch}` first would inflate the diff by every commit
|
||||
* between `origin/main` and a locally-advanced `main`.
|
||||
* 2. **Task-scoped baseCommitSha** — Only when no merge-base is available
|
||||
* (e.g. the base branch was deleted), fall back to the stored SHA if it
|
||||
* is still an ancestor of HEAD.
|
||||
@@ -4596,9 +4599,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
let mergeBase: string | undefined;
|
||||
try {
|
||||
try {
|
||||
mergeBase = (await runGitCommand(["merge-base", "HEAD", `origin/${baseBranch}`], cwd, 5000)).trim() || undefined;
|
||||
} catch {
|
||||
mergeBase = (await runGitCommand(["merge-base", "HEAD", baseBranch], cwd, 5000)).trim() || undefined;
|
||||
} catch {
|
||||
mergeBase = (await runGitCommand(["merge-base", "HEAD", `origin/${baseBranch}`], cwd, 5000)).trim() || undefined;
|
||||
}
|
||||
} catch {
|
||||
// base branch may no longer exist locally
|
||||
|
||||
Reference in New Issue
Block a user