Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion src/lib/components/CommandBlockSettings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,23 @@
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);
let splitMode = $state<app.CommandBlockSplitMode>("enter");
let splitModeReady = $state(false);
let splitModeSaving = $state(false);
let splitModeNote = $state<string | null>(null);
let maxLines = $state(COMMAND_BLOCK_MAX_LINES_DEFAULT);
let maxLinesReady = $state(false);
let maxLinesSaving = $state(false);
let maxLinesNote = $state<string | null>(null);
let promptEnabled = $state(true);
let promptReplacement = $state("anonymous@rssh");
let redactRules = $state<CommandBlockRedactRule[]>([]);
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -242,6 +272,40 @@
{/if}
</fieldset>

<div class="card-divider"></div>
<div class="cmd-block-head">
<div class="cmd-block-head-body">
<label class="cmd-block-title" for="command-block-max-lines">
{t("settings.shell.command_block_max_lines")}
</label>
<div id="command-block-max-lines-desc" class="cmd-block-desc">
{t("settings.shell.command_block_max_lines_desc", {
min: COMMAND_BLOCK_MAX_LINES_MIN,
max: COMMAND_BLOCK_MAX_LINES_MAX,
scrollback: TERMINAL_SCROLLBACK_LINES,
})}
</div>
<div class="cmd-block-desc">{t("settings.shell.command_block_max_lines_new_sessions")}</div>
{#if maxLinesNote}
<div class="inline-error" role="alert">{maxLinesNote}</div>
{/if}
</div>
<input
id="command-block-max-lines"
class="max-lines-input"
type="number"
bind:value={maxLines}
min={COMMAND_BLOCK_MAX_LINES_MIN}
max={COMMAND_BLOCK_MAX_LINES_MAX}
disabled={!maxLinesReady || maxLinesSaving}
aria-describedby="command-block-max-lines-desc"
onblur={saveMaxLines}
onkeydown={(event) => {
if (event.key === "Enter") (event.currentTarget as HTMLInputElement).blur();
}}
/>
</div>

<div class="card-divider"></div>
<div class="cmd-block-head">
<div class="cmd-block-head-body">
Expand Down Expand Up @@ -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 {
Expand Down
42 changes: 28 additions & 14 deletions src/lib/components/TerminalPane.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

function writeRawOutput(raw: Uint8Array) {
Expand Down Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const fold = folded ? foldStore?.getFold(b.id) : undefined;
out.push({
id: b.id,
y: (top - viewportY) * rowHeight,
Expand Down Expand Up @@ -1466,6 +1469,7 @@
onMount(async () => {
terminal = new Terminal({
cursorBlink: true,
scrollback: TERMINAL_SCROLLBACK_LINES,
fontSize: theme.termFontSize(),
fontFamily: theme.currentTermFontStack(),
allowProposedApi: true,
Expand Down Expand Up @@ -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(),
]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (destroyed) return;
blockTracker = createCommandBlockTracker(terminal, commandBlockSplitMode);
blockTracker.onChange(() => {
Expand All @@ -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++; },
Expand All @@ -1639,6 +1650,7 @@
terminal.onRender(schedulePaintTick);
terminal.buffer.onBufferChange((buf) => {
isAltBuffer = buf.type === "alternate";
if (!isAltBuffer) foldStore?.enforceAutoFold();
paintTick++;
});

Expand Down Expand Up @@ -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();
Comment on lines 1723 to 1726

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 77d17dd. Re-enabling the command-block bar now calls enforceAutoFold after fitTerminal, so previously unfolded long blocks are bounded immediately.

if (visible) foldStore?.enforceAutoFold();
});

// Focus terminal + register writer when this tab becomes active.
Expand Down
4 changes: 4 additions & 0 deletions src/lib/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/lib/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "操作提示",
Expand Down
30 changes: 30 additions & 0 deletions src/lib/stores/app.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
});
});
37 changes: 37 additions & 0 deletions src/lib/stores/app.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<number> | null = null;
export function commandBlockMaxLines() { return _commandBlockMaxLines; }
export async function loadCommandBlockMaxLines(): Promise<number> {
if (_cbmlLoaded) return _commandBlockMaxLines;
if (!_cbmlLoad) {
_cbmlLoad = (async () => {
try {
const value = await invoke<string | null>("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; }
Expand Down
18 changes: 18 additions & 0 deletions src/lib/terminal/block-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
16 changes: 14 additions & 2 deletions src/lib/terminal/block-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 绝对行(块还在写)。 */
Expand Down Expand Up @@ -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;
Expand Down
Loading