diff --git a/src/lib/components/CommandBlockSettings.svelte b/src/lib/components/CommandBlockSettings.svelte index 1b1f000c..3e7c648d 100644 --- a/src/lib/components/CommandBlockSettings.svelte +++ b/src/lib/components/CommandBlockSettings.svelte @@ -4,6 +4,12 @@ import { errMsg, t } from "../i18n/index.svelte.ts"; import { toast } from "../stores/toast.svelte.ts"; import type { CommandBlockRedactRule } from "../stores/app.svelte.ts"; + import { + COMMAND_BLOCK_MAX_LINES_DEFAULT, + COMMAND_BLOCK_MAX_LINES_MAX, + COMMAND_BLOCK_MAX_LINES_MIN, + TERMINAL_SCROLLBACK_LINES, + } from "../terminal/limits.ts"; let commandBlockBar = $state(true); let autoColorBlocks = $state(false); @@ -11,6 +17,10 @@ let splitModeReady = $state(false); let splitModeSaving = $state(false); let splitModeNote = $state(null); + let maxLines = $state(COMMAND_BLOCK_MAX_LINES_DEFAULT); + let maxLinesReady = $state(false); + let maxLinesSaving = $state(false); + let maxLinesNote = $state(null); let promptEnabled = $state(true); let promptReplacement = $state("anonymous@rssh"); let redactRules = $state([]); @@ -25,15 +35,18 @@ let confirmRuleDeleteTimer: number | null = null; onMount(async () => { - const [bar, autoColor, loadedSplitMode] = await Promise.all([ + const [bar, autoColor, loadedSplitMode, loadedMaxLines] = await Promise.all([ app.loadCommandBlockBar(), app.loadAutoColorBlocks(), app.loadCommandBlockSplitMode(), + app.loadCommandBlockMaxLines(), ]); commandBlockBar = bar; autoColorBlocks = autoColor; splitMode = loadedSplitMode; splitModeReady = true; + maxLines = loadedMaxLines; + maxLinesReady = true; try { applyRedaction(await app.loadCommandBlockRedaction(true)); redactionReady = true; @@ -79,6 +92,23 @@ } } + async function saveMaxLines() { + if (!maxLinesReady || maxLinesSaving) return; + const previous = app.commandBlockMaxLines(); + maxLinesSaving = true; + maxLinesNote = null; + try { + await app.setCommandBlockMaxLines(maxLines); + maxLines = app.commandBlockMaxLines(); + } catch (error) { + maxLines = previous; + maxLinesNote = t("settings.shell.command_block_max_lines_error", { error: errMsg(error) }); + toast.error(errMsg(error)); + } finally { + maxLinesSaving = false; + } + } + async function savePromptEnabled() { const previous = !promptEnabled; savingPrompt = true; @@ -242,6 +272,40 @@ {/if} +
+
+
+ +
+ {t("settings.shell.command_block_max_lines_desc", { + min: COMMAND_BLOCK_MAX_LINES_MIN, + max: COMMAND_BLOCK_MAX_LINES_MAX, + scrollback: TERMINAL_SCROLLBACK_LINES, + })} +
+
{t("settings.shell.command_block_max_lines_new_sessions")}
+ {#if maxLinesNote} + + {/if} +
+ { + if (event.key === "Enter") (event.currentTarget as HTMLInputElement).blur(); + }} + /> +
+
@@ -496,6 +560,11 @@ white-space: nowrap; } .inline-error { margin-top: 8px; color: var(--error); font-size: 11px; line-height: 1.5; } + .max-lines-input { + width: 88px; + flex: 0 0 auto; + font-variant-numeric: tabular-nums; + } /* 卡片内分隔线:负边距贯穿到卡片左右边缘。 */ .card-divider { diff --git a/src/lib/components/TerminalPane.svelte b/src/lib/components/TerminalPane.svelte index e02a7399..126354f6 100644 --- a/src/lib/components/TerminalPane.svelte +++ b/src/lib/components/TerminalPane.svelte @@ -19,6 +19,7 @@ import {createCommandBlockTracker, type CommandBlock, type CommandBlockTracker} from "../terminal/command-blocks.ts"; import {readViewportSnapshot, readViewportText} from "../terminal/viewport-snapshot.ts"; import {createFoldStore, type FoldStore} from "../terminal/folds.ts"; + import {TERMINAL_SCROLLBACK_LINES, commandBlockFoldCacheLines} from "../terminal/limits.ts"; import {createPaintScheduler, type PaintScheduler} from "../terminal/paint-scheduler.ts"; import {extractBlockTexts, extractBlocksText} from "../terminal/block-content.ts"; @@ -230,14 +231,16 @@ function fitTerminal() { if (destroyed || !terminal) return; // saved fold lines have the current column geometry. Expand them before - // FitAddon calls terminal.resize(), including while the alt buffer is - // active; FoldStore itself is bound to the normal history buffer. + // FitAddon calls terminal.resize(), then reapply the automatic limit to + // the reflowed rows. This also runs while the alt buffer is active; + // FoldStore itself is bound to the normal history buffer and defers the + // re-fold until the normal buffer becomes active again. const proposed = fitAddon?.proposeDimensions(); - if (proposed && terminal - && (proposed.cols !== terminal.cols || proposed.rows !== terminal.rows)) { - foldStore?.unfoldAll(); - } + const dimensionsChanged = !!proposed + && (proposed.cols !== terminal.cols || proposed.rows !== terminal.rows); + if (dimensionsChanged) foldStore?.unfoldAll(); fitAddon?.fit(); + if (dimensionsChanged) foldStore?.enforceAutoFold(); } function writeRawOutput(raw: Uint8Array) { @@ -278,17 +281,17 @@ const out: BlockRect[] = []; for (const b of blockTracker.blocks) { if (b.start.isDisposed) continue; - const folded = foldStore?.isFolded(b.id) ?? false; - // Folded:竖线只标 prompt 行,body 已不在 buffer。 - // Unfolded:常规 [start..end] 跨度。 + const fold = foldStore?.getFold(b.id); + const folded = fold !== undefined; + // Full fold leaves only the command row. An automatic prefix fold + // keeps the newest output rows visible, so its bar still spans them. const startLine = b.start.line; - const endLine = folded + const endLine = fold?.kind === "full" ? b.start.line : b.end && !b.end.isDisposed ? b.end.line : cursorAbs; const top = Math.max(startLine, viewportY); const bot = Math.min(endLine, viewportY + rows - 1); if (top > bot) continue; - const fold = folded ? foldStore?.getFold(b.id) : undefined; out.push({ id: b.id, y: (top - viewportY) * rowHeight, @@ -1466,6 +1469,7 @@ onMount(async () => { terminal = new Terminal({ cursorBlink: true, + scrollback: TERMINAL_SCROLLBACK_LINES, fontSize: theme.termFontSize(), fontFamily: theme.currentTermFontStack(), allowProposedApi: true, @@ -1606,7 +1610,10 @@ // A tracker keeps one split strategy for its complete lifetime. Changing // the preference therefore affects new terminal panes without rewriting // existing markers, folds, selections, or colored blocks. - const commandBlockSplitMode = await app.loadCommandBlockSplitMode(); + const [commandBlockSplitMode, commandBlockMaxLines] = await Promise.all([ + app.loadCommandBlockSplitMode(), + app.loadCommandBlockMaxLines(), + ]); if (destroyed) return; blockTracker = createCommandBlockTracker(terminal, commandBlockSplitMode); blockTracker.onChange(() => { @@ -1629,7 +1636,11 @@ // Fold store — splice-based fold/unfold with auto-cleanup on resize and // scrollback trim. See folds.ts for the invariant analysis. - foldStore = createFoldStore(terminal, blockTracker); + foldStore = createFoldStore(terminal, blockTracker, { + maxVisibleLines: commandBlockMaxLines, + maxCachedLines: commandBlockFoldCacheLines(commandBlockMaxLines), + shouldAutoFold: () => app.commandBlockBar(), + }); paintScheduler = createPaintScheduler({ shouldPaint: () => app.commandBlockBar() && !isAltBuffer, paint: () => { paintTick++; }, @@ -1639,6 +1650,7 @@ terminal.onRender(schedulePaintTick); terminal.buffer.onBufferChange((buf) => { isAltBuffer = buf.type === "alternate"; + if (!isAltBuffer) foldStore?.enforceAutoFold(); paintTick++; }); @@ -1709,8 +1721,10 @@ // without us writing paintTick here (writing it here would make this // effect self-dependent via `++`, causing an update loop). $effect(() => { - app.commandBlockBar(); // subscribe + const visible = app.commandBlockBar(); + if (!visible) foldStore?.unfoldAll(); fitTerminal(); + if (visible) foldStore?.enforceAutoFold(); }); // Focus terminal + register writer when this tab becomes active. diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 8d83ee0a..8da92fbe 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -189,6 +189,10 @@ const en = { "settings.shell.command_block_split_prompt_desc": "Split when a command finishes and a shell prompt is recognized. Custom prompts may be missed; prompt-like output may be mistaken for one.", "settings.shell.command_block_split_new_sessions": "Changes apply to newly opened terminals; existing command blocks are not rewritten.", "settings.shell.command_block_split_error": "Failed to save the command-block boundary: {error}", + "settings.shell.command_block_max_lines": "MAXIMUM OUTPUT LINES", + "settings.shell.command_block_max_lines_desc": "Keep the newest output rows visible ({min}–{max}). Older rows fold automatically; visible and cached rows stay below xterm's {scrollback}-row history limit.", + "settings.shell.command_block_max_lines_new_sessions": "Changes apply to newly opened terminals.", + "settings.shell.command_block_max_lines_error": "Failed to save the command-block line limit: {error}", "settings.shell.command_block_auto_color": "Auto-color blocks", "settings.shell.command_block_auto_color_desc": "Automatically color every new command block — no right-click needed. You can still right-click to uncolor a specific block.", "settings.shell.command_block_tips_title": "Tips", diff --git a/src/lib/i18n/locales/zh.ts b/src/lib/i18n/locales/zh.ts index d8f8ec19..8009241a 100644 --- a/src/lib/i18n/locales/zh.ts +++ b/src/lib/i18n/locales/zh.ts @@ -191,6 +191,10 @@ const zh: Messages = { "settings.shell.command_block_split_prompt_desc": "命令结束并识别到 shell Prompt 时切分。自定义 Prompt 可能漏识别,形似 Prompt 的输出可能误识别。", "settings.shell.command_block_split_new_sessions": "更改仅对新打开的终端生效,现有命令块不会被重写。", "settings.shell.command_block_split_error": "保存命令块切分方式失败:{error}", + "settings.shell.command_block_max_lines": "最大输出行数", + "settings.shell.command_block_max_lines_desc": "保留最新的输出行({min}–{max})。超限后自动折叠最早的行;可见行与缓存行之和严格小于 xterm 的 {scrollback} 行历史上限。", + "settings.shell.command_block_max_lines_new_sessions": "更改仅对新打开的终端生效。", + "settings.shell.command_block_max_lines_error": "保存命令块最大行数失败:{error}", "settings.shell.command_block_auto_color": "自动染色", "settings.shell.command_block_auto_color_desc": "每个新命令块自动染色,无需右键。右键仍可取消单个命令块的染色。", "settings.shell.command_block_tips_title": "操作提示", diff --git a/src/lib/stores/app.svelte.test.ts b/src/lib/stores/app.svelte.test.ts index 1a02a68b..0ea7217c 100644 --- a/src/lib/stores/app.svelte.test.ts +++ b/src/lib/stores/app.svelte.test.ts @@ -526,3 +526,33 @@ describe("command block split mode", () => { }); }); }); + +describe("command block line limit", () => { + it("uses the safe default when unset and persists a user value", async () => { + invokeMock.mockResolvedValueOnce(null); + const app = await loadAppModule(); + + await expect(app.loadCommandBlockMaxLines()).resolves.toBe(50); + await app.setCommandBlockMaxLines(320); + + expect(app.commandBlockMaxLines()).toBe(320); + expect(invokeMock).toHaveBeenCalledWith("set_setting", { + key: "command_block_max_lines", + value: "320", + }); + }); + + it("clamps imported and user-entered values to the supported range", async () => { + invokeMock.mockResolvedValueOnce("50000"); + const app = await loadAppModule(); + + await expect(app.loadCommandBlockMaxLines()).resolves.toBe(900); + await app.setCommandBlockMaxLines(1); + + expect(app.commandBlockMaxLines()).toBe(10); + expect(invokeMock).toHaveBeenCalledWith("set_setting", { + key: "command_block_max_lines", + value: "10", + }); + }); +}); diff --git a/src/lib/stores/app.svelte.ts b/src/lib/stores/app.svelte.ts index 5d03a6c4..3761805a 100644 --- a/src/lib/stores/app.svelte.ts +++ b/src/lib/stores/app.svelte.ts @@ -7,6 +7,10 @@ import type { CommandBlockRedactionSettings as RedactionSettings, } from "../terminal/command-block-redaction.ts"; import type { CommandBlockSplitMode } from "../terminal/command-blocks.ts"; +import { + COMMAND_BLOCK_MAX_LINES_DEFAULT, + normalizeCommandBlockMaxLines, +} from "../terminal/limits.ts"; import type { ViewportSnapshot } from "../terminal/viewport-snapshot.ts"; import { toast } from "./toast.svelte.ts"; @@ -753,6 +757,39 @@ export async function setCommandBlockSplitMode(value: CommandBlockSplitMode) { _cbsmLoaded = true; } +let _commandBlockMaxLines = $state(COMMAND_BLOCK_MAX_LINES_DEFAULT); +let _cbmlLoaded = false; +let _cbmlLoad: Promise | null = null; +export function commandBlockMaxLines() { return _commandBlockMaxLines; } +export async function loadCommandBlockMaxLines(): Promise { + if (_cbmlLoaded) return _commandBlockMaxLines; + if (!_cbmlLoad) { + _cbmlLoad = (async () => { + try { + const value = await invoke("get_setting", { key: "command_block_max_lines" }); + _commandBlockMaxLines = value === null + ? COMMAND_BLOCK_MAX_LINES_DEFAULT + : normalizeCommandBlockMaxLines(value); + } catch (error) { + console.warn("[settings] command-block line limit load failed:", error); + toast.error(errMsg(error)); + } + _cbmlLoaded = true; + return _commandBlockMaxLines; + })(); + } + return _cbmlLoad; +} +export async function setCommandBlockMaxLines(value: number) { + const normalized = normalizeCommandBlockMaxLines(value); + await invoke("set_setting", { + key: "command_block_max_lines", + value: String(normalized), + }); + _commandBlockMaxLines = normalized; + _cbmlLoaded = true; +} + let _commandBlockBar = $state(true); let _cbbLoaded = false; export function commandBlockBar() { return _commandBlockBar; } diff --git a/src/lib/terminal/block-content.test.ts b/src/lib/terminal/block-content.test.ts index 344178d0..49426f99 100644 --- a/src/lib/terminal/block-content.test.ts +++ b/src/lib/terminal/block-content.test.ts @@ -307,6 +307,24 @@ describe("resolveBlockLines (folded blocks)", () => { const lines = resolveBlockLines(term as any, fakeBlock(1, 0, 1), foldStore); expect(lines).toHaveLength(2); }); + + it("prefix fold returns cached oldest rows followed by the visible newest rows", () => { + const promptLine = lineFromSpec("$ build"); + const cached1 = lineFromSpec("step 1"); + const cached2 = lineFromSpec("step 2"); + const visible3 = lineFromSpec("step 3"); + const visible4 = lineFromSpec("step 4"); + const term = fakeTerm([promptLine, visible3, visible4]); + const foldStore = { + getFold: (id: number) => id === 9 + ? { kind: "prefix" as const, savedLines: [cached1, cached2] } + : undefined, + }; + + const lines = resolveBlockLines(term as any, fakeBlock(9, 0, null), foldStore); + + expect(lines).toEqual([promptLine, cached1, cached2, visible3, visible4]); + }); }); describe("extractBlocksText with foldStore", () => { diff --git a/src/lib/terminal/block-content.ts b/src/lib/terminal/block-content.ts index edaa87aa..eab9ece8 100644 --- a/src/lib/terminal/block-content.ts +++ b/src/lib/terminal/block-content.ts @@ -19,7 +19,10 @@ import type { CommandBlock } from "./command-blocks"; /** 折叠仓库的最小接口,只用 getFold 查 saved body。 * 避免直接 import FoldStore,让本模块对 folds.ts 的依赖只剩"读"语义。 */ export interface FoldLookup { - getFold(blockId: number): { savedLines: unknown[] } | undefined; + getFold(blockId: number): { + kind?: "full" | "prefix"; + savedLines: unknown[]; + } | undefined; } /** 一个块的可视行号范围。end 缺失时 fallback 到 cursor 绝对行(块还在写)。 */ @@ -69,7 +72,16 @@ export function resolveBlockLines( if (fold) { const prompt = buf.getLine(block.start.line); const body = fold.savedLines as unknown as IBufferLine[]; - return prompt ? [prompt, ...body] : [...body]; + const out = prompt ? [prompt, ...body] : [...body]; + if (fold.kind !== "prefix") return out; + + const cursorAbs = buf.baseY + buf.cursorY; + const endLine = block.end && !block.end.isDisposed ? block.end.line : cursorAbs; + for (let y = block.start.line + 1; y <= endLine; y++) { + const line = buf.getLine(y); + if (line) out.push(line); + } + return out; } const cursorAbs = buf.baseY + buf.cursorY; const endLine = block.end && !block.end.isDisposed ? block.end.line : cursorAbs; diff --git a/src/lib/terminal/folds.test.ts b/src/lib/terminal/folds.test.ts index a988dc48..aed16851 100644 --- a/src/lib/terminal/folds.test.ts +++ b/src/lib/terminal/folds.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; +import { Terminal } from "@xterm/xterm"; import { createFoldStore } from "./folds.ts"; -import type { CommandBlock, CommandBlockTracker } from "./command-blocks.ts"; +import { createCommandBlockTracker, type CommandBlock, type CommandBlockTracker } from "./command-blocks.ts"; /* ───────────────────────────────────────────────────────────── * Fake xterm.js Terminal —— 复刻 folds.ts 用到的全部私有/公有 API。 @@ -143,6 +144,7 @@ function fakeTerm(opts: { rows: number; initialLines: number; cursorY: number; y const resizeListeners = new Set<() => void>(); const cursorMoveListeners = new Set<() => void>(); const lineFeedListeners = new Set<() => void>(); + const writeParsedListeners = new Set<() => void>(); let activeBufferType: "normal" | "alternate" = "normal"; const term = { @@ -165,6 +167,10 @@ function fakeTerm(opts: { rows: number; initialLines: number; cursorY: number; y lineFeedListeners.add(fn); return { dispose: () => lineFeedListeners.delete(fn) }; }, + onWriteParsed(fn: () => void) { + writeParsedListeners.add(fn); + return { dispose: () => writeParsedListeners.delete(fn) }; + }, _core: { buffer }, }; @@ -184,6 +190,7 @@ function fakeTerm(opts: { rows: number; initialLines: number; cursorY: number; y cursorMoveListeners.forEach((fn) => fn()); }, fireCursorMove: () => cursorMoveListeners.forEach((fn) => fn()), + fireWriteParsed: () => writeParsedListeners.forEach((fn) => fn()), setActiveBuffer: (type: "normal" | "alternate") => { activeBufferType = type; }, @@ -679,6 +686,27 @@ describe("FoldStore — multiple folds", () => { expect(f.snapshot()).toEqual(before); expect(f.lineContents()).toEqual(beforeLines); }); + + it("restores an older fold before reusing its bounded cache budget", () => { + const f = fakeTerm({ rows: 24, initialLines: 24, cursorY: 19 }); + const s1 = f.makeMarker(0); + const e1 = f.makeMarker(5); + const s2 = f.makeMarker(6); + const e2 = f.makeMarker(12); + const store = createFoldStore( + f.term, + fakeTracker([makeBlock(1, s1, e1), makeBlock(2, s2, e2)]), + { maxCachedLines: 6 }, + ); + + expect(store.fold(1)).toBe(true); + expect(store.fold(2)).toBe(true); + + expect(store.isFolded(1)).toBe(false); + expect(store.isFolded(2)).toBe(true); + expect(f.lineContents()).toContain("L1"); + expect(store.folds.reduce((total, fold) => total + fold.savedLines.length, 0)).toBe(6); + }); }); describe("FoldStore — auto-cleanup", () => { @@ -794,3 +822,221 @@ describe("FoldStore — onChange notifications", () => { store.dispose(); }); }); + +describe("FoldStore — automatic command-block limit", () => { + it("does not hide rows when command-block controls are unavailable", () => { + const f = fakeTerm({ rows: 12, initialLines: 12, cursorY: 10 }); + const start = f.makeMarker(0); + const store = createFoldStore( + f.term, + fakeTracker([makeBlock(1, start, null)]), + { maxVisibleLines: 3, maxCachedLines: 8, shouldAutoFold: () => false }, + ); + + f.fireWriteParsed(); + + expect(store.getFold(1)).toBeUndefined(); + expect(f.lineContents().slice(0, 4)).toEqual(["L0", "L1", "L2", "L3"]); + }); + + it("folds the oldest body rows and leaves the newest rows visible", () => { + const f = fakeTerm({ rows: 12, initialLines: 12, cursorY: 10 }); + const start = f.makeMarker(0); + const tracker = fakeTracker([makeBlock(1, start, null)]); + const store = createFoldStore(f.term, tracker, { + maxVisibleLines: 3, + maxCachedLines: 8, + }); + const originalLines = f.lineRefs(); + + f.fireWriteParsed(); + + expect(f.lineContents().slice(0, 4)).toEqual(["L0", "L8", "L9", "L10"]); + expect(store.getFold(1)).toMatchObject({ + kind: "prefix", + count: 7, + }); + expect(store.getFold(1)?.savedLines).toEqual(originalLines.slice(1, 8)); + }); + + it("bounds cached rows and retains the newest restorable prefix", () => { + const f = fakeTerm({ rows: 12, initialLines: 12, cursorY: 10 }); + const start = f.makeMarker(0); + const store = createFoldStore( + f.term, + fakeTracker([makeBlock(1, start, null)]), + { maxVisibleLines: 3, maxCachedLines: 4 }, + ); + const originalLines = f.lineRefs(); + + f.fireWriteParsed(); + + expect(store.getFold(1)?.count).toBe(4); + expect(store.getFold(1)?.savedLines).toEqual(originalLines.slice(4, 8)); + expect(store.folds.reduce((total, fold) => total + fold.savedLines.length, 0)).toBe(4); + }); + + it("unfold restores the cached prefix before the newest visible rows", () => { + const f = fakeTerm({ rows: 12, initialLines: 12, cursorY: 10 }); + const start = f.makeMarker(0); + const store = createFoldStore( + f.term, + fakeTracker([makeBlock(1, start, null)]), + { maxVisibleLines: 3, maxCachedLines: 8 }, + ); + const before = f.lineContents(); + + f.fireWriteParsed(); + expect(store.unfold(1)).toBe(true); + + expect(f.lineContents()).toEqual(before); + }); + + it("can reapply the automatic limit after a terminal reflow unfolds rows", () => { + const f = fakeTerm({ rows: 12, initialLines: 12, cursorY: 10 }); + const start = f.makeMarker(0); + const store = createFoldStore( + f.term, + fakeTracker([makeBlock(1, start, null)]), + { maxVisibleLines: 3, maxCachedLines: 8 }, + ); + + f.fireWriteParsed(); + store.unfoldAll(); + expect(store.getFold(1)).toBeUndefined(); + + store.enforceAutoFold(); + + expect(store.getFold(1)).toMatchObject({ kind: "prefix", count: 7 }); + expect(f.lineContents().slice(0, 4)).toEqual(["L0", "L8", "L9", "L10"]); + }); + + it("folds during a large parse batch before xterm can trim the block marker", () => { + const f = fakeTerm({ rows: 40, initialLines: 40, cursorY: 3 }); + const start = f.makeMarker(0); + const store = createFoldStore( + f.term, + fakeTracker([makeBlock(1, start, null)]), + { maxVisibleLines: 3, maxCachedLines: 36 }, + ); + + for (let line = 0; line < 32; line++) f.lineFeed(); + + expect(start.isDisposed).toBe(false); + expect(store.getFold(1)).toMatchObject({ kind: "prefix", count: 32 }); + expect(f.snapshot().cursorAbs).toBe(3); + }); + + it("releases compensation-row references after live output consumes them", () => { + const f = fakeTerm({ rows: 40, initialLines: 40, cursorY: 3 }); + const start = f.makeMarker(0); + const store = createFoldStore( + f.term, + fakeTracker([makeBlock(1, start, null)]), + { maxVisibleLines: 3, maxCachedLines: 36 }, + ); + + for (let line = 0; line < 320; line++) f.lineFeed(); + + expect(store.getFold(1)?.savedLines).toHaveLength(36); + expect(store.getFold(1)?.pushedBlankRefs.length).toBeLessThanOrEqual(64); + }); +}); + +describe("FoldStore — automatic limit (real xterm contract)", () => { + const writeP = (term: Terminal, data: string) => + new Promise((resolve) => term.write(data, resolve)); + + it("keeps a live block marker and the newest rows through a large write", async () => { + const term = new Terminal({ + cols: 80, + rows: 5, + allowProposedApi: true, + scrollback: 1_000, + }); + const tracker = createCommandBlockTracker(term); + const store = createFoldStore(term, tracker, { + maxVisibleLines: 3, + maxCachedLines: 20, + }); + term.input("\r"); + + await writeP(term, Array.from({ length: 40 }, (_, index) => `L${index}`).join("\r\n")); + + const block = tracker.blocks[0]; + expect(block.start.isDisposed).toBe(false); + expect(term.buffer.active.baseY + term.buffer.active.cursorY - block.start.line).toBe(3); + expect(store.getFold(block.id)).toMatchObject({ kind: "prefix", count: 20 }); + expect(term.buffer.active.getLine(block.start.line + 1)?.translateToString(true)).toBe("L37"); + + expect(store.unfold(block.id)).toBe(true); + expect(store.isFolded(block.id)).toBe(false); + expect(term.buffer.active.getLine(block.start.line + 1)?.translateToString(true)).toBe("L17"); + + store.dispose(); + tracker.dispose(); + term.dispose(); + }); + + it("folds the tail of a block closed by prompt detection in the same parse batch", async () => { + const term = new Terminal({ + cols: 80, + rows: 5, + allowProposedApi: true, + scrollback: 1_000, + }); + const tracker = createCommandBlockTracker(term, "prompt"); + const store = createFoldStore(term, tracker, { + maxVisibleLines: 3, + maxCachedLines: 20, + }); + + await writeP(term, "$ "); + term.input("echo test\r"); + await writeP( + term, + `echo test\r\n${Array.from({ length: 10 }, (_, index) => `L${index}`).join("\r\n")}\r\n$ `, + ); + + expect(tracker.blocks).toHaveLength(2); + const completed = tracker.blocks[0]; + expect(completed.end).not.toBeNull(); + expect(store.getFold(completed.id)).toMatchObject({ kind: "prefix", count: 7 }); + expect(term.buffer.active.getLine(completed.start.line + 1)?.translateToString(true)).toBe("L7"); + + store.dispose(); + tracker.dispose(); + term.dispose(); + }); + + it("bounds a command larger than xterm scrollback and still unfolds useful history", async () => { + const term = new Terminal({ + cols: 80, + rows: 5, + allowProposedApi: true, + scrollback: 1_000, + }); + const tracker = createCommandBlockTracker(term); + const store = createFoldStore(term, tracker, { + maxVisibleLines: 900, + maxCachedLines: 99, + }); + term.input("\r"); + + await writeP(term, Array.from({ length: 1_500 }, (_, index) => `L${index}`).join("\r\n")); + + const block = tracker.blocks[0]; + expect(block.start.isDisposed).toBe(false); + expect(term.buffer.active.baseY + term.buffer.active.cursorY - block.start.line).toBe(900); + expect(store.getFold(block.id)).toMatchObject({ kind: "prefix", count: 99 }); + + expect(store.unfold(block.id)).toBe(true); + expect(block.start.isDisposed).toBe(false); + expect(term.buffer.active.baseY + term.buffer.active.cursorY - block.start.line).toBe(999); + expect(term.buffer.active.getLine(block.start.line + 1)?.translateToString(true)).toBe("L501"); + + store.dispose(); + tracker.dispose(); + term.dispose(); + }); +}); diff --git a/src/lib/terminal/folds.ts b/src/lib/terminal/folds.ts index 6caf3621..e8b3e73d 100644 --- a/src/lib/terminal/folds.ts +++ b/src/lib/terminal/folds.ts @@ -9,7 +9,8 @@ * 3. 不变量 cursor 内容跟随:splice 在 cursor 上方时 cursor 绝对行 * 要相应减少(fold)或增加(unfold) * - * fold 流程:splice 抽出 → push 空行补齐(记下引用)→ drain ybase 再 y → 重排 ydisp + * fold 流程:splice 抽出 → push 空行补齐(记下引用)→ drain ybase 再 y → 重排 ydisp。 + * 手动折叠抽完整 body;自动折叠只抽最早的前缀,保留最新输出可见。 * * unfold 流程:splice 塞回 → 删除本 fold 当初补在 buffer 里的空行。 * 补偿空行可能已被后续 fold 推到 buffer 中间;因此不能只看末尾, @@ -21,6 +22,10 @@ * - block.start 死亡(scrollback 修剪到该 block 之前)— 通过监听 * tracker.onChange 检测 block 从 tracker 消失来代理 * + * 缓存不变量:所有 fold.savedLines 的总数受 maxCachedLines 限制。预算 + * 满时先展开旧 fold,把历史交还 xterm;单个超长活动块只保留最新的 + * 可恢复前缀。调用方保证 visible + cached < xterm scrollback。 + * * ⚠️ Private-API warning: depends on _core.buffer's lines/ybase/ydisp/y/ * getBlankLine/addMarker, plus _core._viewport.queueSync (scrollbar resync). * package.json pins "@xterm/xterm": "6.0.0". Any xterm bump must re-run @@ -29,12 +34,14 @@ * requires re-checking these private hooks against the new build by hand. */ import type { Terminal, IDisposable, IMarker } from "@xterm/xterm"; -import type { CommandBlockTracker } from "./command-blocks"; +import type { CommandBlock, CommandBlockTracker } from "./command-blocks"; export interface Fold { /** 自增 id(仅用于调试);外界以 blockId 索引 */ id: number; blockId: number; + /** full = user folded the complete closed block; prefix = automatic oldest rows. */ + kind: "full" | "prefix"; /** body 行数 */ count: number; /** splice 抽出的 BufferLine 实例(对我们透明) */ @@ -60,12 +67,27 @@ export interface FoldStore extends IDisposable { getFold(blockId: number): Fold | undefined; /** Expand every fold before xterm changes row/column geometry. */ unfoldAll(): void; + /** Reapply the configured automatic limit after xterm reflows its rows. */ + enforceAutoFold(): void; /** 折叠状态变化时通知(fold/unfold/scrollback 失效)。 */ onChange(fn: () => void): IDisposable; } +export interface FoldStoreOptions { + /** Keep this many newest body rows visible in a growing command block. */ + maxVisibleLines?: number; + /** Global saved-line budget across every fold in this terminal. */ + maxCachedLines?: number; + /** Visual block controls must be available before automatic folding hides rows. */ + shouldAutoFold?: () => boolean; +} + /** xterm 默认 attr(fg=0,bg=0),与 DEFAULT_ATTR_DATA 等价。getBlankLine 必填。 */ const BLANK_ATTR = { fg: 0, bg: 0, extended: { ext: 0, urlId: 0, underlineStyle: 0 } }; +// Bound work inside one xterm parse batch without splicing on every LF. The +// settings cap leaves at least 99 scrollback rows, so 32 is safely below the +// distance at which xterm could trim the current block's start marker. +const AUTO_FOLD_BATCH_LINES = 32; interface PrivateBuffer { lines: { @@ -132,7 +154,11 @@ function isStillBlankLine(line: unknown): boolean { return true; } -export function createFoldStore(term: Terminal, tracker: CommandBlockTracker): FoldStore { +export function createFoldStore( + term: Terminal, + tracker: CommandBlockTracker, + options: FoldStoreOptions = {}, +): FoldStore { // 以 blockId 为键便于 O(1) 判断"该 block 是否折叠"。Fold 的 id 仅用于调试。 const folds = new Map(); // Compensation-line identity -> owning fold/index. Cursor events can update @@ -144,39 +170,95 @@ export function createFoldStore(term: Terminal, tracker: CommandBlockTracker): F const emit = () => listeners.forEach((fn) => fn()); - function fold(blockId: number): boolean { - if (folds.has(blockId)) return false; - const block = tracker.blocks.find((b) => b.id === blockId); - if (!block || !block.end) return false; - if (block.start.isDisposed || block.end.isDisposed) return false; - const startLine = block.start.line + 1; - const endLine = block.end.line; - if (startLine > endLine) return false; // 空 body + const maxVisibleLines = Number.isFinite(options.maxVisibleLines) + ? Math.max(1, Math.trunc(options.maxVisibleLines!)) + : null; + const maxCachedLines = Number.isFinite(options.maxCachedLines) + ? Math.max(1, Math.trunc(options.maxCachedLines!)) + : Number.POSITIVE_INFINITY; + + function discardFold(f: FoldState): void { + for (const line of f.pushedBlankRefs) { + const owner = blankOwners.get(line); + if (owner?.blockId === f.blockId) blankOwners.delete(line); + } + } + + function pruneConsumedBlankRefs(f: FoldState): void { + const consumed = Math.min(f.consumedBlankCount, f.pushedBlankRefs.length); + if (consumed === 0) return; + for (const line of f.pushedBlankRefs.slice(0, consumed)) { + const owner = blankOwners.get(line); + if (owner?.blockId === f.blockId) blankOwners.delete(line); + } + f.pushedBlankRefs.splice(0, consumed); + f.consumedBlankCount = 0; + f.pushedBlankRefs.forEach((line, index) => { + blankOwners.set(line, { blockId: f.blockId, index }); + }); + } + + /** Drop the oldest saved rows first. The xterm buffer has the same policy, + * and keeping a larger shadow history would only make unfold trim it again. */ + function enforceSavedLineBudget(currentBlockId: number): void { + if (!Number.isFinite(maxCachedLines)) return; + let overflow = Array.from(folds.values()) + .reduce((total, item) => total + item.savedLines.length, 0) - maxCachedLines; + if (overflow <= 0) return; + + // Restore older folds into xterm before reusing their shadow-cache budget. + // Silently deleting a full fold would leave only its command row with no + // label and no way to recover the body. + for (const blockId of Array.from(folds.keys())) { + if (blockId === currentBlockId) continue; + unfold(blockId); + overflow = Array.from(folds.values()) + .reduce((total, item) => total + item.savedLines.length, 0) - maxCachedLines; + if (overflow <= 0) return; + } + + // One long current block can still exceed the whole budget. Match xterm's + // head-trim policy and retain the newest restorable prefix rows. + const current = folds.get(currentBlockId); + if (!current || overflow <= 0) return; + current.savedLines.splice(0, Math.min(overflow, current.savedLines.length)); + current.count = current.savedLines.length; + } + + function removeLines( + blockId: number, + kind: Fold["kind"], + startLine: number, + count: number, + ): boolean { + if (count <= 0) return false; + const existing = folds.get(blockId); + if (existing && (existing.kind !== "prefix" || kind !== "prefix")) return false; + if (existing) { + recordCursorConsumption(); + for (let i = existing.consumedBlankCount; i < existing.pushedBlankRefs.length; i++) { + if (!isStillBlankLine(existing.pushedBlankRefs[i])) { + existing.consumedBlankCount = i + 1; + } + } + pruneConsumedBlankRefs(existing); + } const buf = getBuf(term); const cursorAbs = buf.ybase + buf.y; - if (endLine >= cursorAbs) return false; // 折叠区间含 cursor 或之后 — 拒绝 - const count = endLine - startLine + 1; - // 抓 wasLive 在 mutation 之前——和 unfold 对称。用户在底部活线时折叠 - // 上方旧块,ydisp -= count 会把视口推上去脱离底部,体感像"自动滚动"。 + const endLine = startLine + count - 1; + if (endLine >= cursorAbs) return false; const wasLive = buf.ydisp === buf.ybase; const saved: unknown[] = []; for (let i = 0; i < count; i++) saved.push(buf.lines.get(startLine + i)); - // 抽 ybase 让出 scrollback 空间,剩下的部分用 push 空行补。 - // 关键:push 数量 = count - ybaseDrain(不是 count!),否则 lines.length - // 会比实际需要的多 ybaseDrain 行,造成滚动条与内容不同步。 + // Drain scrollback first; only deletions from the visible screen need + // compensation rows to preserve lines.length === ybase + rows. const ybaseDrain = Math.min(buf.ybase, count); const pushCount = count - ybaseDrain; - - // splice 抽出 → marker 自动迁移 + 范围内 marker 自动 dispose(含 block.end) buf.lines.splice(startLine, count); - // 不变量 (1):lines.length === ybase + rows - // splice 后 lines.length 减了 count - // ybase 减 ybaseDrain,rows 不变 - // 缺口 = count - ybaseDrain = pushCount → 末尾补 pushCount 行 const pushedRefs: unknown[] = []; for (let i = 0; i < pushCount; i++) { const blank = buf.getBlankLine(BLANK_ATTR); @@ -184,38 +266,94 @@ export function createFoldStore(term: Terminal, tracker: CommandBlockTracker): F pushedRefs.push(blank); } - // 不变量 (2):cursor 跟随内容。绝对位置 -= count。 - // ybase 让 ybaseDrain;y 让 pushCount。和 = count。 buf.ybase -= ybaseDrain; - buf.y -= pushCount; - if (buf.y < 0) buf.y = 0; - - // 视口顶端:在 splice 后则减 count;在区间内塌到 startLine;最后夹到 [0, ybase] + buf.y = Math.max(0, buf.y - pushCount); if (buf.ydisp >= startLine + count) buf.ydisp -= count; else if (buf.ydisp >= startLine) buf.ydisp = startLine; if (buf.ydisp > buf.ybase) buf.ydisp = buf.ybase; - // wasLive 钉在底部:上面的位移逻辑会把活线模式打破,这里把它拉回来 if (wasLive) buf.ydisp = buf.ybase; - const foldState: FoldState = { - id: nextId++, blockId, count, - savedLines: saved, pushedBlankRefs: pushedRefs, consumedBlankCount: 0, + const foldState = existing ?? { + id: nextId++, + blockId, + kind, + count: 0, + savedLines: [], + pushedBlankRefs: [], + consumedBlankCount: 0, }; + foldState.savedLines.push(...saved); + foldState.count = foldState.savedLines.length; + const blankOffset = foldState.pushedBlankRefs.length; + foldState.pushedBlankRefs.push(...pushedRefs); folds.set(blockId, foldState); - pushedRefs.forEach((line, index) => blankOwners.set(line, { blockId, index })); + pushedRefs.forEach((line, index) => { + blankOwners.set(line, { blockId, index: blankOffset + index }); + }); + enforceSavedLineBudget(blockId); syncViewport(term); term.refresh(0, term.rows - 1); emit(); return true; } - function discardFold(f: FoldState): void { - for (const line of f.pushedBlankRefs) { - const owner = blankOwners.get(line); - if (owner?.blockId === f.blockId) blankOwners.delete(line); + function fold(blockId: number): boolean { + if (folds.has(blockId)) return false; + const block = tracker.blocks.find((b) => b.id === blockId); + if (!block || !block.end) return false; + if (block.start.isDisposed || block.end.isDisposed) return false; + const startLine = block.start.line + 1; + const endLine = block.end.line; + if (startLine > endLine) return false; // 空 body + + const count = endLine - startLine + 1; + return removeLines(blockId, "full", startLine, count); + } + + function canAutoFold(): boolean { + return maxVisibleLines !== null + && options.shouldAutoFold?.() !== false + && term.buffer.active.type === "normal"; + } + + function foldBlockOverflow(block: CommandBlock, minimumExcess: number): void { + if (maxVisibleLines === null || block.start.isDisposed) return; + if (folds.get(block.id)?.kind === "full") return; + const buf = getBuf(term); + const endLine = block.end === null + ? buf.ybase + buf.y + : block.end.isDisposed ? null : block.end.line; + if (endLine === null) return; + const visibleBodyLines = endLine - block.start.line; + const excess = visibleBodyLines - maxVisibleLines; + if (excess >= minimumExcess) { + removeLines(block.id, "prefix", block.start.line + 1, excess); } } + function foldActiveOverflow(minimumExcess: number): void { + if (!canAutoFold()) return; + const block = tracker.blocks[tracker.blocks.length - 1]; + if (block?.end === null) foldBlockOverflow(block, minimumExcess); + } + + function foldRecentOverflow(): void { + if (!canAutoFold()) return; + const blocks = tracker.blocks; + // Prompt detection runs before this listener and may close the previous + // block while opening a new prompt block in the same parse batch. + for (let i = Math.max(0, blocks.length - 2); i < blocks.length; i++) { + foldBlockOverflow(blocks[i], 1); + } + } + + function enforceAutoFold(): void { + if (!canAutoFold()) return; + // Markers migrate as older rows are removed, so a stable block snapshot is + // enough even though removeLines mutates the xterm buffer underneath it. + for (const block of Array.from(tracker.blocks)) foldBlockOverflow(block, 1); + } + function recordCursorConsumption(): void { if (folds.size === 0) return; // Cursor/line-feed events belong to the active buffer. Folds own normal @@ -342,14 +480,15 @@ export function createFoldStore(term: Terminal, tracker: CommandBlockTracker): F buf.y = clamp(nextCursorAbs - buf.ybase, 0, term.rows - 1); buf.ydisp = wasLive ? buf.ybase : clamp(nextYdisp, 0, buf.ybase); - // 重装 block.end:splice 时它被 dispose,block-bar 渲染依赖它的位置 - try { - const newEnd = buf.addMarker(block.start.line + f.count); - // CommandBlock.end 字段未声明 readonly,可直接赋值;tracker 不知情 - // 但本 FoldStore 自己 emit 让消费者重绘。 - (block as { end: IMarker | null }).end = newEnd; - } catch { - // addMarker 异常则保持 end=disposed,block-bar 会回退到 cursor 位置 — 可接受 + // A full fold consumed block.end, so recreate it. Prefix folds leave the + // newest rows in the buffer; a later end marker migrates with insertion. + if (f.kind === "full") { + try { + const newEnd = buf.addMarker(block.start.line + f.count); + (block as { end: IMarker | null }).end = newEnd; + } catch { + // Keep the disposed marker; renderers already fall back to the cursor. + } } discardFold(f); @@ -369,8 +508,14 @@ export function createFoldStore(term: Terminal, tracker: CommandBlockTracker): F // high-water mark when output later moves the cursor back up. disposables.push( term.onCursorMove(recordCursorConsumption), - term.onLineFeed(recordCursorConsumption), + term.onLineFeed(() => { + recordCursorConsumption(); + foldActiveOverflow(AUTO_FOLD_BATCH_LINES); + }), ); + if (maxVisibleLines !== null) { + disposables.push(term.onWriteParsed(foldRecentOverflow)); + } // scrollback 修剪:tracker 监听 block.start.onDispose 后从 blocks 数组移除。 // 这里通过 onChange 比对 tracker 现存 block — 折叠记录里若 block 不在了,丢弃。 @@ -401,6 +546,7 @@ export function createFoldStore(term: Terminal, tracker: CommandBlockTracker): F return folds.get(blockId); }, unfoldAll, + enforceAutoFold, onChange(fn) { listeners.add(fn); return { dispose: () => listeners.delete(fn) }; diff --git a/src/lib/terminal/limits.test.ts b/src/lib/terminal/limits.test.ts new file mode 100644 index 00000000..d9e8455f --- /dev/null +++ b/src/lib/terminal/limits.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { + COMMAND_BLOCK_MAX_LINES_DEFAULT, + COMMAND_BLOCK_MAX_LINES_MAX, + COMMAND_BLOCK_MAX_LINES_MIN, + TERMINAL_SCROLLBACK_LINES, + commandBlockFoldCacheLines, + normalizeCommandBlockMaxLines, +} from "./limits.ts"; + +describe("command-block terminal limits", () => { + it("normalizes missing, fractional, and out-of-range settings", () => { + expect(normalizeCommandBlockMaxLines(null)).toBe(COMMAND_BLOCK_MAX_LINES_DEFAULT); + expect(normalizeCommandBlockMaxLines("not-a-number")).toBe(COMMAND_BLOCK_MAX_LINES_DEFAULT); + expect(normalizeCommandBlockMaxLines(42.9)).toBe(42); + expect(normalizeCommandBlockMaxLines(1)).toBe(COMMAND_BLOCK_MAX_LINES_MIN); + expect(normalizeCommandBlockMaxLines(50_000)).toBe(COMMAND_BLOCK_MAX_LINES_MAX); + }); + + it("keeps visible plus cached rows strictly below xterm scrollback", () => { + for (const visible of [COMMAND_BLOCK_MAX_LINES_MIN, 200, COMMAND_BLOCK_MAX_LINES_MAX]) { + expect(visible + commandBlockFoldCacheLines(visible)).toBeLessThan(TERMINAL_SCROLLBACK_LINES); + } + }); +}); diff --git a/src/lib/terminal/limits.ts b/src/lib/terminal/limits.ts new file mode 100644 index 00000000..c2294081 --- /dev/null +++ b/src/lib/terminal/limits.ts @@ -0,0 +1,31 @@ +/** + * Terminal history limits shared by xterm and command-block folding. + * + * Keep one explicit scrollback value instead of relying on xterm's implicit + * default. An auto-folded block retains at most the history that can be put + * back without exceeding that same budget: + * + * visible block rows + cached folded rows < xterm scrollback + */ +export const TERMINAL_SCROLLBACK_LINES = 1_000; + +export const COMMAND_BLOCK_MAX_LINES_DEFAULT = 50; +export const COMMAND_BLOCK_MAX_LINES_MIN = 10; +// Leave at least 99 cached rows even at the largest user-selectable value. +export const COMMAND_BLOCK_MAX_LINES_MAX = 900; + +export function normalizeCommandBlockMaxLines(value: unknown): number { + if (value === null || value === undefined || value === "") { + return COMMAND_BLOCK_MAX_LINES_DEFAULT; + } + const parsed = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(parsed)) return COMMAND_BLOCK_MAX_LINES_DEFAULT; + return Math.max( + COMMAND_BLOCK_MAX_LINES_MIN, + Math.min(COMMAND_BLOCK_MAX_LINES_MAX, Math.trunc(parsed)), + ); +} + +export function commandBlockFoldCacheLines(maxVisibleLines: number): number { + return TERMINAL_SCROLLBACK_LINES - normalizeCommandBlockMaxLines(maxVisibleLines) - 1; +}