FN-6536: apply terminal preferences to sessions
Extend embedded session terminals to reuse the saved TerminalModal preferences while preserving replay safety. - Apply shared font, font size, cursor style, cursor blink, and renderer preferences when SessionTerminal initializes. - Live-apply font and cursor preference changes from storage events without remounting the terminal. - Keep cursor blink disabled for read-only, idle, and ended sessions, and skip WebGL on mobile viewports. - Add desktop and mobile tests plus dashboard documentation for embedded session preference behavior. Files changed: docs/dashboard-guide.md | 1 + .../dashboard/app/components/SessionTerminal.tsx | 96 +++++++++--- .../__tests__/SessionTerminal.mobile.test.tsx | 53 ++++++- .../components/__tests__/SessionTerminal.test.tsx | 166 +++++++++++++++++++-- 4 files changed, 286 insertions(+), 30 deletions(-) Fusion-Task-Id: FN-6536 Fusion-Task-Lineage: 836eafd7-923d-45b7-9cf6-e1236af8f168
This commit is contained in:
@@ -341,6 +341,7 @@ Features:
|
||||
- The Shortcuts panel includes Ctrl/Alt helpers, ESC/Tab, common shell shortcuts, and Up/Down/Left/Right arrow buttons that send standard ANSI cursor sequences for keyboard-less shell history and line editing
|
||||
- The Preferences panel customizes font family, font size, cursor style, cursor blink, and renderer; changes persist in browser `localStorage` under `kb-terminal-preferences`, with the legacy `kb-terminal-font-size` value migrated automatically
|
||||
- Font and cursor preferences apply live to the active xterm instance; renderer changes apply the next time the terminal opens, and mobile devices keep the WebGL renderer disabled to avoid glyph artifacts
|
||||
- Embedded CLI session terminals honor the same saved preferences for live, idle, ended, read-only, and interactive session views. Cursor blink still stays disabled for read-only/replay sessions, renderer changes apply on the next session mount, and WebGL never loads on mobile viewports.
|
||||
- Mobile-aware virtual keyboard handling and auto-refit behavior
|
||||
- Reopen/reconnect/session-recovery flows preserve single-keystroke input forwarding (no duplicate characters, no page refresh required)
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ import { appendTokenQuery } from "../auth";
|
||||
import { api } from "../api";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
import { isMobileViewport, MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode";
|
||||
import {
|
||||
TERMINAL_PREFERENCES_KEY,
|
||||
readTerminalPreferences,
|
||||
resolveTerminalFontFamily,
|
||||
} from "../utils/terminalPreferences";
|
||||
|
||||
/**
|
||||
* SessionTerminal (CLI Agent Executor, U11) — shared xterm terminal for a CLI
|
||||
@@ -249,6 +254,45 @@ export function SessionTerminal({
|
||||
if (showConfirmAdvance) setAdvanceDismissed(false);
|
||||
}, [showConfirmAdvance, sessionId]);
|
||||
|
||||
const applyLiveTerminalPreferences = useCallback(() => {
|
||||
const terminal = xtermRef.current;
|
||||
if (!terminal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const terminalPreferences = readTerminalPreferences();
|
||||
terminal.options.fontFamily = resolveTerminalFontFamily(terminalPreferences.fontFamily);
|
||||
terminal.options.fontSize = terminalPreferences.fontSize;
|
||||
terminal.options.cursorStyle = terminalPreferences.cursorStyle;
|
||||
terminal.options.cursorBlink = terminalPreferences.cursorBlink && !readOnly && mode === "live";
|
||||
|
||||
try {
|
||||
(fitAddonRef.current as { fit?: () => void } | null)?.fit?.();
|
||||
} catch {
|
||||
/* ignore transient measure failures */
|
||||
}
|
||||
}, [mode, readOnly]);
|
||||
|
||||
/*
|
||||
FNXC:Terminal 2026-06-17-01:05:
|
||||
Font and cursor preferences live-apply through the shared storage key so SessionTerminal follows changes made in another terminal surface without remounting. Renderer remains excluded from this handler because renderer addon teardown/re-attach only happens safely during the next session init.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key !== TERMINAL_PREFERENCES_KEY) {
|
||||
return;
|
||||
}
|
||||
applyLiveTerminalPreferences();
|
||||
};
|
||||
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => window.removeEventListener("storage", onStorage);
|
||||
}, [applyLiveTerminalPreferences]);
|
||||
|
||||
// ── xterm lifecycle + WS bridge ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!sessionId || typeof window === "undefined") return;
|
||||
@@ -295,16 +339,23 @@ export function SessionTerminal({
|
||||
]);
|
||||
if (disposed || !containerRef.current) return;
|
||||
|
||||
const terminalPreferences = readTerminalPreferences();
|
||||
const resolvedFontFamily = resolveTerminalFontFamily(terminalPreferences.fontFamily);
|
||||
|
||||
/*
|
||||
FNXC:Terminal 2026-06-17-00:50:
|
||||
SessionTerminal consumes the shared localStorage terminal preferences for parity with TerminalModal, but replay safety still owns input posture: cursor blink is the user preference AND-gated by !readOnly && mode === "live" so read-only, idle, and ended sessions never blink.
|
||||
*/
|
||||
const term = new Terminal({
|
||||
convertEol: false,
|
||||
cursorBlink: !readOnly && mode === "live",
|
||||
cursorBlink: terminalPreferences.cursorBlink && !readOnly && mode === "live",
|
||||
cursorStyle: terminalPreferences.cursorStyle,
|
||||
disableStdin: readOnly,
|
||||
scrollback: 10000,
|
||||
// Defensive: do NOT register an OSC 52 (clipboard-write) handler. The
|
||||
// server-side neutralizer (U10) strips it; we add no client handling.
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||
fontSize: 13,
|
||||
fontFamily: resolvedFontFamily,
|
||||
fontSize: terminalPreferences.fontSize,
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
@@ -316,22 +367,29 @@ export function SessionTerminal({
|
||||
xtermRef.current = term;
|
||||
fitAddonRef.current = fitAddon as unknown as ITerminalAddon;
|
||||
|
||||
// WebGL renderer with context-loss fallback to the DOM renderer.
|
||||
try {
|
||||
const { WebglAddon } = await import("@xterm/addon-webgl");
|
||||
if (!disposed) {
|
||||
const webgl = new WebglAddon();
|
||||
webgl.onContextLoss(() => {
|
||||
try {
|
||||
webgl.dispose();
|
||||
} catch {
|
||||
/* fall back to DOM renderer */
|
||||
}
|
||||
});
|
||||
term.loadAddon(webgl);
|
||||
/*
|
||||
FNXC:Terminal 2026-06-17-00:55:
|
||||
The embedded session terminal follows the shared renderer preference, but mobile viewports are a hard WebGL skip floor to avoid glyph artifacts in WebKit. Renderer changes are init-only because swapping xterm render addons mid-session is unsafe; users get the new renderer on the next mount/session.
|
||||
*/
|
||||
const shouldLoadWebgl = terminalPreferences.renderer === "auto" && !isMobileViewport();
|
||||
if (shouldLoadWebgl) {
|
||||
// WebGL renderer with context-loss fallback to the DOM renderer.
|
||||
try {
|
||||
const { WebglAddon } = await import("@xterm/addon-webgl");
|
||||
if (!disposed) {
|
||||
const webgl = new WebglAddon();
|
||||
webgl.onContextLoss(() => {
|
||||
try {
|
||||
webgl.dispose();
|
||||
} catch {
|
||||
/* fall back to DOM renderer */
|
||||
}
|
||||
});
|
||||
term.loadAddon(webgl);
|
||||
}
|
||||
} catch {
|
||||
/* WebGL unavailable — DOM renderer is the default fallback */
|
||||
}
|
||||
} catch {
|
||||
/* WebGL unavailable — DOM renderer is the default fallback */
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
|
||||
import { MOBILE_MEDIA_QUERY } from "../../hooks/useViewportMode";
|
||||
|
||||
// ── Mock xterm + addon dynamic imports (jsdom has no canvas/WebGL) ──────────
|
||||
const mockFitAddon = { fit: vi.fn() };
|
||||
const mockTerm = {
|
||||
loadAddon: vi.fn(),
|
||||
open: vi.fn(),
|
||||
@@ -11,11 +12,12 @@ const mockTerm = {
|
||||
write: vi.fn((_data: string, cb?: () => void) => cb?.()),
|
||||
dispose: vi.fn(),
|
||||
unicode: { activeVersion: "6" },
|
||||
options: {} as Record<string, unknown>,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
};
|
||||
vi.mock("@xterm/xterm", () => ({ Terminal: vi.fn(function Terminal() { return mockTerm; }) }));
|
||||
vi.mock("@xterm/addon-fit", () => ({ FitAddon: vi.fn(function FitAddon() { return { fit: vi.fn() }; }) }));
|
||||
vi.mock("@xterm/xterm", () => ({ Terminal: vi.fn(function Terminal(options) { mockTerm.options = { ...options }; return mockTerm; }) }));
|
||||
vi.mock("@xterm/addon-fit", () => ({ FitAddon: vi.fn(function FitAddon() { return mockFitAddon; }) }));
|
||||
vi.mock("@xterm/addon-unicode11", () => ({ Unicode11Addon: vi.fn(function Unicode11Addon() { return {}; }) }));
|
||||
vi.mock("@xterm/addon-webgl", () => ({
|
||||
WebglAddon: vi.fn(function WebglAddon() { return { onContextLoss: vi.fn(), dispose: vi.fn() }; }),
|
||||
@@ -91,6 +93,7 @@ function stubScreen(width: number, height: number) {
|
||||
}
|
||||
|
||||
import { SessionTerminal } from "../SessionTerminal";
|
||||
import { DEFAULT_TERMINAL_PREFERENCES, TERMINAL_PREFERENCES_KEY } from "../../utils/terminalPreferences";
|
||||
|
||||
/** Pull the parsed input frames a WS has sent. */
|
||||
function inputFrames(ws: FakeWS): string[] {
|
||||
@@ -111,8 +114,14 @@ beforeEach(() => {
|
||||
FakeWS.instances = [];
|
||||
originalWebSocket = (globalThis as typeof globalThis & { WebSocket?: typeof WebSocket }).WebSocket;
|
||||
(globalThis as unknown as { WebSocket: typeof FakeWS }).WebSocket = FakeWS;
|
||||
window.localStorage.clear();
|
||||
mockTerm.loadAddon.mockClear();
|
||||
mockTerm.open.mockClear();
|
||||
mockTerm.onData.mockReset();
|
||||
mockTerm.write.mockClear();
|
||||
mockTerm.dispose.mockClear();
|
||||
mockTerm.options = {};
|
||||
mockFitAddon.fit.mockClear();
|
||||
apiMock.mockReset();
|
||||
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: false });
|
||||
installMatchMedia(true); // mobile by default
|
||||
@@ -298,6 +307,41 @@ describe("SessionTerminal (mobile)", () => {
|
||||
await renderMobile();
|
||||
expect(mockTerm.onData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never loads WebGL on mobile even when renderer preference is auto", async () => {
|
||||
const { WebglAddon } = await import("@xterm/addon-webgl");
|
||||
window.localStorage.setItem(
|
||||
TERMINAL_PREFERENCES_KEY,
|
||||
JSON.stringify({ ...DEFAULT_TERMINAL_PREFERENCES, renderer: "auto" }),
|
||||
);
|
||||
|
||||
await renderMobile();
|
||||
|
||||
expect(WebglAddon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the accessory key bar intact while applying terminal preferences", async () => {
|
||||
window.localStorage.setItem(
|
||||
TERMINAL_PREFERENCES_KEY,
|
||||
JSON.stringify({
|
||||
...DEFAULT_TERMINAL_PREFERENCES,
|
||||
fontFamily: "fira-code",
|
||||
cursorStyle: "underline",
|
||||
}),
|
||||
);
|
||||
|
||||
await renderMobile();
|
||||
|
||||
expect(screen.getByTestId("cli-terminal-key-bar")).toBeTruthy();
|
||||
expect(screen.getByTestId("cli-key-ctrl")).toBeTruthy();
|
||||
expect(screen.getByTestId("cli-key-esc")).toBeTruthy();
|
||||
expect(screen.getByTestId("cli-key-tab")).toBeTruthy();
|
||||
expect(screen.getByTestId("cli-key-ctrl-c")).toBeTruthy();
|
||||
expect(screen.getByTestId("cli-key-arrow-up")).toBeTruthy();
|
||||
expect(screen.getByTestId("cli-key-arrow-down")).toBeTruthy();
|
||||
expect(screen.getByTestId("cli-key-arrow-left")).toBeTruthy();
|
||||
expect(screen.getByTestId("cli-key-arrow-right")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Keyboard-open (fixed-footer) + pinch-zoom guard ──────────────────────────
|
||||
@@ -345,7 +389,12 @@ describe("SessionTerminal (mobile) — keyboard-open behavior", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
FakeWS.instances = [];
|
||||
window.localStorage.clear();
|
||||
mockTerm.loadAddon.mockClear();
|
||||
mockTerm.open.mockClear();
|
||||
mockTerm.onData.mockReset();
|
||||
mockTerm.options = {};
|
||||
mockFitAddon.fit.mockClear();
|
||||
apiMock.mockReset();
|
||||
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: false });
|
||||
installMatchMedia(true);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
|
||||
// ── Mock xterm + addon dynamic imports (jsdom has no canvas/WebGL) ──────────
|
||||
const mockFitAddon = { fit: vi.fn() };
|
||||
const mockTerm = {
|
||||
loadAddon: vi.fn(),
|
||||
open: vi.fn(),
|
||||
@@ -10,11 +11,12 @@ const mockTerm = {
|
||||
write: vi.fn((_data: string, cb?: () => void) => cb?.()),
|
||||
dispose: vi.fn(),
|
||||
unicode: { activeVersion: "6" },
|
||||
options: {} as Record<string, unknown>,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
};
|
||||
vi.mock("@xterm/xterm", () => ({ Terminal: vi.fn(function Terminal() { return mockTerm; }) }));
|
||||
vi.mock("@xterm/addon-fit", () => ({ FitAddon: vi.fn(function FitAddon() { return { fit: vi.fn() }; }) }));
|
||||
vi.mock("@xterm/xterm", () => ({ Terminal: vi.fn(function Terminal(options) { mockTerm.options = { ...options }; return mockTerm; }) }));
|
||||
vi.mock("@xterm/addon-fit", () => ({ FitAddon: vi.fn(function FitAddon() { return mockFitAddon; }) }));
|
||||
vi.mock("@xterm/addon-unicode11", () => ({ Unicode11Addon: vi.fn(function Unicode11Addon() { return {}; }) }));
|
||||
vi.mock("@xterm/addon-webgl", () => ({
|
||||
WebglAddon: vi.fn(function WebglAddon() { return { onContextLoss: vi.fn(), dispose: vi.fn() }; }),
|
||||
@@ -51,14 +53,24 @@ let originalWebSocket: typeof WebSocket | undefined;
|
||||
};
|
||||
|
||||
import { SessionTerminal } from "../SessionTerminal";
|
||||
import {
|
||||
DEFAULT_TERMINAL_PREFERENCES,
|
||||
TERMINAL_PREFERENCES_KEY,
|
||||
} from "../../utils/terminalPreferences";
|
||||
|
||||
beforeEach(() => {
|
||||
FakeWS.instances = [];
|
||||
originalWebSocket = (globalThis as typeof globalThis & { WebSocket?: typeof WebSocket }).WebSocket;
|
||||
(globalThis as unknown as { WebSocket: typeof FakeWS }).WebSocket = FakeWS;
|
||||
window.localStorage.clear();
|
||||
mockTerm.loadAddon.mockClear();
|
||||
mockTerm.open.mockClear();
|
||||
mockTerm.onData.mockReset();
|
||||
mockTerm.attachCustomKeyEventHandler.mockClear();
|
||||
mockTerm.write.mockClear();
|
||||
mockTerm.dispose.mockClear();
|
||||
mockTerm.options = {};
|
||||
mockFitAddon.fit.mockClear();
|
||||
apiMock.mockReset();
|
||||
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: false });
|
||||
});
|
||||
@@ -97,7 +109,7 @@ describe("SessionTerminal", () => {
|
||||
expect(mockTerm.onData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("relies on native xterm paste with the system monospace font", async () => {
|
||||
it("relies on native xterm paste while applying the default terminal font preference", async () => {
|
||||
const { Terminal } = await import("@xterm/xterm");
|
||||
|
||||
render(<SessionTerminal sessionId="s1" />);
|
||||
@@ -105,12 +117,10 @@ describe("SessionTerminal", () => {
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
expect(Terminal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fontFamily: expect.stringContaining("ui-monospace"),
|
||||
}),
|
||||
);
|
||||
expect(Terminal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fontFamily: expect.not.stringContaining("Fusion Terminal Nerd Font Symbols"),
|
||||
fontFamily: expect.stringContaining("Fusion Terminal Nerd Font Symbols"),
|
||||
fontSize: DEFAULT_TERMINAL_PREFERENCES.fontSize,
|
||||
cursorStyle: DEFAULT_TERMINAL_PREFERENCES.cursorStyle,
|
||||
cursorBlink: DEFAULT_TERMINAL_PREFERENCES.cursorBlink,
|
||||
}),
|
||||
);
|
||||
expect(mockTerm.attachCustomKeyEventHandler).not.toHaveBeenCalled();
|
||||
@@ -126,6 +136,144 @@ describe("SessionTerminal", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies validated terminal preferences at xterm init", async () => {
|
||||
const { Terminal } = await import("@xterm/xterm");
|
||||
window.localStorage.setItem(
|
||||
TERMINAL_PREFERENCES_KEY,
|
||||
JSON.stringify({
|
||||
fontFamily: "system-mono",
|
||||
fontSize: 18,
|
||||
cursorStyle: "underline",
|
||||
cursorBlink: true,
|
||||
renderer: "auto",
|
||||
}),
|
||||
);
|
||||
|
||||
render(<SessionTerminal sessionId="s1" />);
|
||||
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
expect(Terminal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||
fontSize: 18,
|
||||
cursorStyle: "underline",
|
||||
cursorBlink: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to safe default preferences for corrupt storage", async () => {
|
||||
const { Terminal } = await import("@xterm/xterm");
|
||||
window.localStorage.setItem(TERMINAL_PREFERENCES_KEY, "{not-json");
|
||||
|
||||
render(<SessionTerminal sessionId="s1" />);
|
||||
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
expect(Terminal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fontFamily: expect.stringContaining("Fusion Terminal Nerd Font Symbols"),
|
||||
fontSize: DEFAULT_TERMINAL_PREFERENCES.fontSize,
|
||||
cursorStyle: DEFAULT_TERMINAL_PREFERENCES.cursorStyle,
|
||||
cursorBlink: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "read-only", props: { readOnly: true } },
|
||||
{ label: "idle", props: { mode: "idle" as const } },
|
||||
{ label: "ended", props: { mode: "ended" as const } },
|
||||
])("keeps cursor blink disabled for $label sessions", async ({ props }) => {
|
||||
const { Terminal } = await import("@xterm/xterm");
|
||||
window.localStorage.setItem(
|
||||
TERMINAL_PREFERENCES_KEY,
|
||||
JSON.stringify({ ...DEFAULT_TERMINAL_PREFERENCES, cursorBlink: true }),
|
||||
);
|
||||
|
||||
render(<SessionTerminal sessionId="s1" {...props} />);
|
||||
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
expect(Terminal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cursorBlink: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips WebGL on desktop when renderer preference is canvas", async () => {
|
||||
const { WebglAddon } = await import("@xterm/addon-webgl");
|
||||
window.localStorage.setItem(
|
||||
TERMINAL_PREFERENCES_KEY,
|
||||
JSON.stringify({ ...DEFAULT_TERMINAL_PREFERENCES, renderer: "canvas" }),
|
||||
);
|
||||
|
||||
render(<SessionTerminal sessionId="s1" />);
|
||||
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
await waitFor(() => expect(mockTerm.open).toHaveBeenCalled());
|
||||
expect(WebglAddon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads WebGL on desktop when renderer preference is auto", async () => {
|
||||
const { WebglAddon } = await import("@xterm/addon-webgl");
|
||||
window.localStorage.setItem(
|
||||
TERMINAL_PREFERENCES_KEY,
|
||||
JSON.stringify({ ...DEFAULT_TERMINAL_PREFERENCES, renderer: "auto" }),
|
||||
);
|
||||
|
||||
render(<SessionTerminal sessionId="s1" />);
|
||||
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
await waitFor(() => expect(WebglAddon).toHaveBeenCalled());
|
||||
expect(mockTerm.loadAddon).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ onContextLoss: expect.any(Function) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("live-applies font and cursor preference changes from storage events", async () => {
|
||||
render(<SessionTerminal sessionId="s1" />);
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
mockFitAddon.fit.mockClear();
|
||||
|
||||
window.localStorage.setItem(
|
||||
TERMINAL_PREFERENCES_KEY,
|
||||
JSON.stringify({
|
||||
fontFamily: "jetbrains-mono",
|
||||
fontSize: 20,
|
||||
cursorStyle: "bar",
|
||||
cursorBlink: false,
|
||||
renderer: "canvas",
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(new StorageEvent("storage", { key: TERMINAL_PREFERENCES_KEY }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTerm.options).toMatchObject({
|
||||
fontFamily:
|
||||
'"JetBrains Mono", "JetBrainsMono Nerd Font", ui-monospace, SFMono-Regular, monospace',
|
||||
fontSize: 20,
|
||||
cursorStyle: "bar",
|
||||
cursorBlink: false,
|
||||
});
|
||||
});
|
||||
expect(mockFitAddon.fit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores unrelated storage events when live-applying preferences", async () => {
|
||||
render(<SessionTerminal sessionId="s1" />);
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
mockFitAddon.fit.mockClear();
|
||||
|
||||
window.localStorage.setItem(
|
||||
TERMINAL_PREFERENCES_KEY,
|
||||
JSON.stringify({ ...DEFAULT_TERMINAL_PREFERENCES, fontSize: 22 }),
|
||||
);
|
||||
window.dispatchEvent(new StorageEvent("storage", { key: "unrelated" }));
|
||||
|
||||
expect(mockTerm.options.fontSize).not.toBe(22);
|
||||
expect(mockFitAddon.fit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the Read-only badge when readOnly", async () => {
|
||||
render(<SessionTerminal sessionId="s1" readOnly />);
|
||||
expect(await screen.findByText("Read-only")).toBeTruthy();
|
||||
|
||||
Reference in New Issue
Block a user