feat(FN-2609): merge fusion/fn-2609 (auto-resolved)
- test(FN-2609): complete Step 4 — cover terminal font-size controls - feat(FN-2609): complete Step 3 — style terminal font-size controls - feat(FN-2609): complete Step 2 — add terminal status bar font controls - feat(FN-2609): complete Step 1 — persist terminal font size state
This commit is contained in:
@@ -654,6 +654,45 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.terminal-font-size-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.terminal-font-size-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: calc(var(--space-lg) + var(--space-md) + var(--space-xs));
|
||||
min-height: calc(var(--space-lg) + var(--space-md) + var(--space-xs));
|
||||
padding: 0;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.terminal-font-size-btn:hover {
|
||||
background: var(--card-hover);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.terminal-font-size-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.terminal-font-size-value {
|
||||
min-width: calc(var(--space-xl) + var(--space-xs));
|
||||
text-align: center;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.terminal-reconnect-btn,
|
||||
.terminal-restart-btn {
|
||||
display: flex;
|
||||
@@ -838,6 +877,11 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.terminal-font-size-btn {
|
||||
min-width: calc(var(--space-xl) + var(--space-md));
|
||||
min-height: calc(var(--space-xl) + var(--space-md));
|
||||
}
|
||||
|
||||
.terminal-shortcuts kbd {
|
||||
padding: 1px 4px;
|
||||
font-size: 10px;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "./TerminalModal.css";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import { X, Trash2, Terminal as TerminalIcon, RefreshCw } from "lucide-react";
|
||||
import { X, Trash2, Terminal as TerminalIcon, RefreshCw, Minus, Plus } from "lucide-react";
|
||||
import { useTerminal } from "../hooks/useTerminal";
|
||||
import { useTerminalSessions } from "../hooks/useTerminalSessions";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
@@ -13,6 +13,36 @@ import type { FitAddon } from "@xterm/addon-fit";
|
||||
const XTERM_INIT_TIMEOUT_MS = 10000;
|
||||
|
||||
const XTERM_IMPORT_RETRY_DELAYS_MS = [500, 1500, 3000] as const;
|
||||
const TERMINAL_FONT_SIZE_KEY = "kb-terminal-font-size";
|
||||
const DEFAULT_FONT_SIZE = 14;
|
||||
const MIN_TERMINAL_FONT_SIZE = 8;
|
||||
const MAX_TERMINAL_FONT_SIZE = 32;
|
||||
|
||||
function clampTerminalFontSize(value: number): number {
|
||||
return Math.min(MAX_TERMINAL_FONT_SIZE, Math.max(MIN_TERMINAL_FONT_SIZE, value));
|
||||
}
|
||||
|
||||
function readInitialTerminalFontSize(): number {
|
||||
if (typeof window === "undefined") {
|
||||
return DEFAULT_FONT_SIZE;
|
||||
}
|
||||
|
||||
try {
|
||||
const savedFontSize = window.localStorage.getItem(TERMINAL_FONT_SIZE_KEY);
|
||||
if (!savedFontSize) {
|
||||
return DEFAULT_FONT_SIZE;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(savedFontSize, 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DEFAULT_FONT_SIZE;
|
||||
}
|
||||
|
||||
return clampTerminalFontSize(parsed);
|
||||
} catch {
|
||||
return DEFAULT_FONT_SIZE;
|
||||
}
|
||||
}
|
||||
|
||||
function isRetryableDynamicImportError(error: unknown): boolean {
|
||||
const message =
|
||||
@@ -152,6 +182,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
const [openGeneration, setOpenGeneration] = useState(0);
|
||||
const [keyboardOverlap, setKeyboardOverlap] = useState(0);
|
||||
const [viewportHeight, setViewportHeight] = useState<number | null>(null);
|
||||
const [fontSize, setFontSize] = useState<number>(() => readInitialTerminalFontSize());
|
||||
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
@@ -161,6 +192,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
const xtermInitializedRef = useRef<string | false>(false);
|
||||
const resizeRef = useRef<((cols: number, rows: number) => void) | null>(null);
|
||||
const keyboardOverlapRef = useRef(0);
|
||||
const fontSizeRef = useRef(fontSize);
|
||||
/** Tracks a pending requestAnimationFrame for deferred xterm re-fit. */
|
||||
const pendingFitRef = useRef<number | null>(null);
|
||||
/** Tracks the previous projectId to detect project switches and invalidate xterm. */
|
||||
@@ -169,6 +201,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
// Keep the latest keyboard overlap in a ref so async xterm setup can read
|
||||
// current mobile keyboard state without forcing the init effect to re-run.
|
||||
keyboardOverlapRef.current = keyboardOverlap;
|
||||
fontSizeRef.current = fontSize;
|
||||
|
||||
/**
|
||||
* Fit xterm and publish cols/rows for a specific terminal session.
|
||||
@@ -305,6 +338,32 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
// without needing resize as a dependency (avoids ordering issues).
|
||||
resizeRef.current = resize;
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(TERMINAL_FONT_SIZE_KEY, String(fontSize));
|
||||
} catch {
|
||||
// Ignore localStorage persistence errors.
|
||||
}
|
||||
}, [fontSize]);
|
||||
|
||||
const refitTerminal = useCallback(() => {
|
||||
const terminal = xtermRef.current;
|
||||
if (!terminal) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
(fitAddonRef.current as InstanceType<typeof FitAddon> | null)?.fit();
|
||||
resize(terminal.cols, terminal.rows);
|
||||
} catch {
|
||||
// Ignore fit errors during viewport transitions.
|
||||
}
|
||||
}, [resize]);
|
||||
|
||||
// Initialize xterm.js when session is ready.
|
||||
// Keying this effect by active session id (not full activeTab object) avoids
|
||||
// tearing down xterm lifecycle wiring during unrelated tab metadata updates
|
||||
@@ -370,7 +429,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
terminal = new TerminalCtor({
|
||||
cursorBlink: true,
|
||||
cursorStyle: "block",
|
||||
fontSize: 14,
|
||||
fontSize: fontSizeRef.current,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||
theme: {
|
||||
background: "#1e1e1e",
|
||||
@@ -623,6 +682,15 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
}
|
||||
}, [connectionStatus, initialCommand, sendInput, activeTab, openGeneration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!xtermReady || !xtermRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
xtermRef.current.options.fontSize = fontSize;
|
||||
refitTerminal();
|
||||
}, [fontSize, xtermReady, refitTerminal]);
|
||||
|
||||
// Handle keyboard shortcuts (zoom)
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -633,39 +701,31 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
// Zoom in: Ctrl/Cmd + Plus
|
||||
if (e.code === "Equal" || e.code === "NumpadAdd") {
|
||||
e.preventDefault();
|
||||
if (xtermRef.current) {
|
||||
const currentSize = xtermRef.current.options.fontSize || 14;
|
||||
xtermRef.current.options.fontSize = Math.min(currentSize + 1, 32);
|
||||
(fitAddonRef.current as InstanceType<typeof FitAddon> | null)?.fit();
|
||||
}
|
||||
setFontSize((current) => clampTerminalFontSize(current + 1));
|
||||
refitTerminal();
|
||||
return;
|
||||
}
|
||||
|
||||
// Zoom out: Ctrl/Cmd + Minus
|
||||
if (e.code === "Minus" || e.code === "NumpadSubtract") {
|
||||
e.preventDefault();
|
||||
if (xtermRef.current) {
|
||||
const currentSize = xtermRef.current.options.fontSize || 14;
|
||||
xtermRef.current.options.fontSize = Math.max(currentSize - 1, 8);
|
||||
(fitAddonRef.current as InstanceType<typeof FitAddon> | null)?.fit();
|
||||
}
|
||||
setFontSize((current) => clampTerminalFontSize(current - 1));
|
||||
refitTerminal();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset zoom: Ctrl/Cmd + 0
|
||||
if (e.code === "Digit0" || e.code === "Numpad0") {
|
||||
e.preventDefault();
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.options.fontSize = 14;
|
||||
(fitAddonRef.current as InstanceType<typeof FitAddon> | null)?.fit();
|
||||
}
|
||||
setFontSize(DEFAULT_FONT_SIZE);
|
||||
refitTerminal();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen]);
|
||||
}, [isOpen, refitTerminal]);
|
||||
|
||||
// Handle escape key to close
|
||||
useEffect(() => {
|
||||
@@ -836,6 +896,16 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
const handleIncreaseFontSize = useCallback(() => {
|
||||
setFontSize((current) => clampTerminalFontSize(current + 1));
|
||||
refitTerminal();
|
||||
}, [refitTerminal]);
|
||||
|
||||
const handleDecreaseFontSize = useCallback(() => {
|
||||
setFontSize((current) => clampTerminalFontSize(current - 1));
|
||||
refitTerminal();
|
||||
}, [refitTerminal]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getStatusIndicator = () => {
|
||||
@@ -1072,6 +1142,29 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
Exit: {exitCode}
|
||||
</span>
|
||||
)}
|
||||
<span className="terminal-font-size-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-font-size-btn"
|
||||
onClick={handleDecreaseFontSize}
|
||||
data-testid="terminal-font-size-decrease"
|
||||
aria-label="Decrease terminal font size"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<span className="terminal-font-size-value" data-testid="terminal-font-size-value">
|
||||
{fontSize}px
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-font-size-btn"
|
||||
onClick={handleIncreaseFontSize}
|
||||
data-testid="terminal-font-size-increase"
|
||||
aria-label="Increase terminal font size"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</span>
|
||||
<span className="terminal-shortcuts">
|
||||
Ctrl++/- zoom • Ctrl+L clear • Esc close
|
||||
</span>
|
||||
|
||||
@@ -62,6 +62,7 @@ const mockUseTerminal = vi.mocked(useTerminalModule.useTerminal);
|
||||
const mockUseTerminalSessions = vi.mocked(useTerminalSessionsModule.useTerminalSessions);
|
||||
const mockCreateTerminalSession = vi.mocked(apiModule.createTerminalSession);
|
||||
const mockKillPtyTerminalSession = vi.mocked(apiModule.killPtyTerminalSession);
|
||||
const TERMINAL_FONT_SIZE_KEY = "kb-terminal-font-size";
|
||||
|
||||
// Default tab state
|
||||
const defaultTab = {
|
||||
@@ -107,6 +108,8 @@ describe("TerminalModal", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.removeItem(TERMINAL_FONT_SIZE_KEY);
|
||||
mockTerminalInstance.options.fontSize = 14;
|
||||
mockCreateTerminalSession.mockResolvedValue({
|
||||
sessionId: "test-session-123",
|
||||
shell: "/bin/bash",
|
||||
@@ -516,6 +519,96 @@ describe("TerminalModal", () => {
|
||||
expect(mockTerminalInstance.open).toHaveBeenCalledWith(terminalDiv);
|
||||
});
|
||||
|
||||
describe("font size controls", () => {
|
||||
it("renders controls in the status bar with default font-size value", async () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-font-size-decrease")).toBeTruthy();
|
||||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("14px");
|
||||
expect(screen.getByTestId("terminal-font-size-increase")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("increases font size via button and persists to localStorage", async () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("terminal-font-size-increase"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("15px");
|
||||
expect(window.localStorage.getItem(TERMINAL_FONT_SIZE_KEY)).toBe("15");
|
||||
});
|
||||
});
|
||||
|
||||
it("decreases font size via button and persists to localStorage", async () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("terminal-font-size-decrease"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("13px");
|
||||
expect(window.localStorage.getItem(TERMINAL_FONT_SIZE_KEY)).toBe("13");
|
||||
});
|
||||
});
|
||||
|
||||
it("reads persisted font size from localStorage on mount", async () => {
|
||||
window.localStorage.setItem(TERMINAL_FONT_SIZE_KEY, "18");
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("18px");
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps button changes to max 32", async () => {
|
||||
window.localStorage.setItem(TERMINAL_FONT_SIZE_KEY, "32");
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("terminal-font-size-increase"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("32px");
|
||||
expect(window.localStorage.getItem(TERMINAL_FONT_SIZE_KEY)).toBe("32");
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps button changes to min 8", async () => {
|
||||
window.localStorage.setItem(TERMINAL_FONT_SIZE_KEY, "8");
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("terminal-font-size-decrease"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("8px");
|
||||
expect(window.localStorage.getItem(TERMINAL_FONT_SIZE_KEY)).toBe("8");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps keyboard zoom shortcuts wired to shared font-size state", async () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
fireEvent.keyDown(window, { ctrlKey: true, code: "Equal" });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("15px");
|
||||
});
|
||||
|
||||
fireEvent.keyDown(window, { ctrlKey: true, code: "Minus" });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("14px");
|
||||
});
|
||||
|
||||
fireEvent.keyDown(window, { ctrlKey: true, code: "Digit0" });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("14px");
|
||||
expect(window.localStorage.getItem(TERMINAL_FONT_SIZE_KEY)).toBe("14");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("xterm container is rendered (visible under loading overlay) while loading", async () => {
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
...defaultSessionState,
|
||||
|
||||
Reference in New Issue
Block a user