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:
5
.changeset/fix-dashboard-tui-footer-overlap.md
Normal file
5
.changeset/fix-dashboard-tui-footer-overlap.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix `fn dashboard` Logs tab row budgeting so log lines stay above the footer hint on short terminals, including wrapped-message cases.
|
||||||
@@ -748,7 +748,7 @@ describe("DashboardTUI Logs viewport window", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tui = createTestTUI();
|
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";
|
(tui as any).activeSection = "logs";
|
||||||
|
|
||||||
stdoutWriteSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
stdoutWriteSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||||
@@ -782,7 +782,8 @@ describe("DashboardTUI Logs viewport window", () => {
|
|||||||
const output = renderLogs();
|
const output = renderLogs();
|
||||||
|
|
||||||
expect(output).toContain("Entry 5");
|
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(output).not.toContain("Entry 12");
|
||||||
expect((tui as any).logsViewportStart).toBe(4);
|
expect((tui as any).logsViewportStart).toBe(4);
|
||||||
});
|
});
|
||||||
@@ -797,7 +798,60 @@ describe("DashboardTUI Logs viewport window", () => {
|
|||||||
expect((tui as any).selectedLogIndex).toBe(11);
|
expect((tui as any).selectedLogIndex).toBe(11);
|
||||||
output = renderLogs();
|
output = renderLogs();
|
||||||
expect(output).toContain("Entry 12");
|
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 {
|
private getFooterTopRow(totalRows: number): number {
|
||||||
if (totalEntries <= 0) {
|
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;
|
this.logsViewportStart = 0;
|
||||||
return 0;
|
return { start: 0, end: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxStart = Math.max(0, totalEntries - maxRows);
|
const maxStart = Math.max(0, entries.length - 1);
|
||||||
let start = Math.min(this.logsViewportStart, maxStart);
|
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) {
|
if (safeSelectedIndex < start) {
|
||||||
start = this.selectedLogIndex;
|
start = safeSelectedIndex;
|
||||||
} else if (this.selectedLogIndex >= start + maxRows) {
|
|
||||||
start = this.selectedLogIndex - maxRows + 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logsViewportStart = Math.max(0, Math.min(start, maxStart));
|
const measureWindow = (startIndex: number): { end: number } => {
|
||||||
return this.logsViewportStart;
|
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 {
|
private renderLogsSection(): void {
|
||||||
const cols = process.stdout.columns || 80;
|
const cols = process.stdout.columns || 80;
|
||||||
|
const rows = process.stdout.rows ?? 38;
|
||||||
const entries = this.logBuffer.getAll();
|
const entries = this.logBuffer.getAll();
|
||||||
// Clamp to minimum 1 row to handle very small terminals.
|
const rowBudget = this.getLogsListRowBudget(rows);
|
||||||
// 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);
|
|
||||||
|
|
||||||
process.stdout.write(colorize("\n LOGS\n", "bold"));
|
process.stdout.write(colorize("\n LOGS\n", "bold"));
|
||||||
process.stdout.write(colorize(` Ring buffer: ${this.logBuffer.total}/${MAX_LOG_ENTRIES} entries\n`, "dim"));
|
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");
|
const modeIndicator = this.logsWrapEnabled ? colorize(" [w] wrap on", "dim") : colorize(" [w] wrap off", "dim");
|
||||||
process.stdout.write(modeIndicator + "\n\n");
|
process.stdout.write(modeIndicator + "\n\n");
|
||||||
|
|
||||||
// Calculate viewport window from selection, so every ring-buffer entry remains reachable.
|
if (rowBudget === 0) {
|
||||||
const startIndex = this.getLogViewportStart(entries.length, maxRows);
|
process.stdout.write(colorize(" Terminal too short — expand terminal to view logs.\n", "dim"));
|
||||||
const visibleEntries = entries.slice(startIndex, startIndex + maxRows);
|
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();
|
const visibleReversed = [...visibleEntries].reverse();
|
||||||
|
|
||||||
// Map selected index to display index (for highlighting in newest-first list)
|
// 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)
|
? visibleEntries.length - 1 - (safeSelectedIndex - startIndex)
|
||||||
: -1;
|
: -1;
|
||||||
|
|
||||||
@@ -845,7 +901,9 @@ export class DashboardTUI {
|
|||||||
const prefixLen = 30; // timestamp + level + prefix overhead
|
const prefixLen = 30; // timestamp + level + prefix overhead
|
||||||
const availableWidth = Math.max(8, cols - prefixLen);
|
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 entry = visibleReversed[displayIdx];
|
||||||
const isSelected = displayIdx === selectedDisplayIndex;
|
const isSelected = displayIdx === selectedDisplayIndex;
|
||||||
|
|
||||||
@@ -858,15 +916,21 @@ export class DashboardTUI {
|
|||||||
: colorize("✓", "brightGreen");
|
: colorize("✓", "brightGreen");
|
||||||
|
|
||||||
if (this.logsWrapEnabled) {
|
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 wrappedLines = this.wrapText(entry.message, availableWidth);
|
||||||
|
const lineBudget = Math.max(1, remainingRows);
|
||||||
|
const renderedLines = wrappedLines.slice(0, lineBudget);
|
||||||
|
|
||||||
// First line includes prefix
|
// 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");
|
process.stdout.write(visibleTruncate(firstLine, cols - 1) + "\n");
|
||||||
|
remainingRows--;
|
||||||
|
|
||||||
// Continuation lines (indented)
|
// Continuation lines (indented)
|
||||||
for (let i = 1; i < wrappedLines.length; i++) {
|
for (let i = 1; i < renderedLines.length && remainingRows > 0; i++) {
|
||||||
const continuation = ` ${wrappedLines[i]}`;
|
const continuation = ` ${renderedLines[i]}`;
|
||||||
process.stdout.write(visibleTruncate(continuation, cols - 1) + "\n");
|
process.stdout.write(visibleTruncate(continuation, cols - 1) + "\n");
|
||||||
|
remainingRows--;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Single-line mode: truncate to available width
|
// Single-line mode: truncate to available width
|
||||||
@@ -874,6 +938,7 @@ export class DashboardTUI {
|
|||||||
const message = visibleTruncate(entry.message, messageWidth);
|
const message = visibleTruncate(entry.message, messageWidth);
|
||||||
const line = `${selector}${ts} ${levelChar} ${prefix ? prefix + " " : ""}${message}`;
|
const line = `${selector}${ts} ${levelChar} ${prefix ? prefix + " " : ""}${message}`;
|
||||||
process.stdout.write(visibleTruncate(line, cols - 1) + "\n");
|
process.stdout.write(visibleTruncate(line, cols - 1) + "\n");
|
||||||
|
remainingRows--;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2333,7 +2333,8 @@ describe("SettingsModal", () => {
|
|||||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
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 () => {
|
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):
|
* Strategy (in priority order):
|
||||||
* 1. **Branch merge-base** — Prefer the live merge-base between HEAD and
|
* 1. **Branch merge-base** — Prefer the live merge-base between HEAD and
|
||||||
* `origin/{baseBranch}` (or bare `{baseBranch}`). This stays correct as
|
* the local `{baseBranch}` ref (falling back to `origin/{baseBranch}`
|
||||||
* the base branch advances and is merged into the feature branch.
|
* 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
|
* 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
|
* (e.g. the base branch was deleted), fall back to the stored SHA if it
|
||||||
* is still an ancestor of HEAD.
|
* is still an ancestor of HEAD.
|
||||||
@@ -4596,9 +4599,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
let mergeBase: string | undefined;
|
let mergeBase: string | undefined;
|
||||||
try {
|
try {
|
||||||
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;
|
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 {
|
} catch {
|
||||||
// base branch may no longer exist locally
|
// base branch may no longer exist locally
|
||||||
|
|||||||
Reference in New Issue
Block a user