feat(FN-2612): merge fusion/fn-2612 (auto-resolved)
- test(FN-2612): complete Step 4 — cover shortcut helpers and panel interactions - feat(FN-2612): complete Step 3 — style shortcut panel controls - feat(FN-2612): complete Step 2 — add terminal shortcut panel UI - feat(FN-2612): complete Step 1 — add control sequence helpers
This commit is contained in:
@@ -621,6 +621,61 @@
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-shortcut-panel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
max-height: calc(var(--space-2xl) + var(--space-xl) + var(--space-lg));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.terminal-shortcut-modifier-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
width: 100%;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.terminal-shortcut-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: calc(var(--space-xl) + var(--space-xs));
|
||||
min-height: calc(var(--space-xl) + var(--space-xs));
|
||||
padding: 0 var(--space-xs);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.terminal-shortcut-btn:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.terminal-shortcut-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.terminal-shortcut-btn--modifier {
|
||||
min-width: calc(var(--space-xl) + var(--space-md) + var(--space-xs));
|
||||
}
|
||||
|
||||
.terminal-shortcut-btn--modifier.is-active {
|
||||
background: var(--in-progress);
|
||||
color: var(--card);
|
||||
border-color: var(--in-progress);
|
||||
}
|
||||
|
||||
.terminal-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -877,6 +932,17 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.terminal-shortcut-panel {
|
||||
max-height: calc(var(--space-2xl) + var(--space-2xl) + var(--space-2xl) + var(--space-xl));
|
||||
}
|
||||
|
||||
.terminal-shortcut-btn,
|
||||
.terminal-shortcut-btn--modifier {
|
||||
min-width: calc(var(--space-xl) + var(--space-md));
|
||||
min-height: calc(var(--space-xl) + var(--space-md));
|
||||
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));
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import "./TerminalModal.css";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import { X, Trash2, Terminal as TerminalIcon, RefreshCw, Minus, Plus } from "lucide-react";
|
||||
import {
|
||||
X,
|
||||
Trash2,
|
||||
Terminal as TerminalIcon,
|
||||
RefreshCw,
|
||||
Minus,
|
||||
Plus,
|
||||
Keyboard,
|
||||
} from "lucide-react";
|
||||
import { useTerminal } from "../hooks/useTerminal";
|
||||
import { useTerminalSessions } from "../hooks/useTerminalSessions";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
@@ -18,6 +26,48 @@ const DEFAULT_FONT_SIZE = 14;
|
||||
const MIN_TERMINAL_FONT_SIZE = 8;
|
||||
const MAX_TERMINAL_FONT_SIZE = 32;
|
||||
|
||||
export function ctrlChar(key: string): string {
|
||||
if (!key) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const normalized = key.slice(0, 1).toUpperCase();
|
||||
|
||||
if (normalized === "[") {
|
||||
return "\x1b";
|
||||
}
|
||||
|
||||
if (normalized >= "A" && normalized <= "Z") {
|
||||
return String.fromCharCode(normalized.charCodeAt(0) - 64);
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
export function altChar(key: string): string {
|
||||
return `\x1b${key}`;
|
||||
}
|
||||
|
||||
interface ShortcutKey {
|
||||
label: string;
|
||||
key: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const SHORTCUT_KEYS: ShortcutKey[] = [
|
||||
{ label: "C", key: "c", description: "SigInt" },
|
||||
{ label: "D", key: "d", description: "EOF" },
|
||||
{ label: "Z", key: "z", description: "Suspend" },
|
||||
{ label: "L", key: "l", description: "Clear" },
|
||||
{ label: "R", key: "r", description: "Reverse search" },
|
||||
{ label: "A", key: "a", description: "Home" },
|
||||
{ label: "E", key: "e", description: "End" },
|
||||
{ label: "U", key: "u", description: "Kill line" },
|
||||
{ label: "K", key: "k", description: "Kill to EOL" },
|
||||
{ label: "W", key: "w", description: "Del word" },
|
||||
{ label: ".", key: ".", description: "Last argument" },
|
||||
];
|
||||
|
||||
function clampTerminalFontSize(value: number): number {
|
||||
return Math.min(MAX_TERMINAL_FONT_SIZE, Math.max(MIN_TERMINAL_FONT_SIZE, value));
|
||||
}
|
||||
@@ -183,6 +233,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
const [keyboardOverlap, setKeyboardOverlap] = useState(0);
|
||||
const [viewportHeight, setViewportHeight] = useState<number | null>(null);
|
||||
const [fontSize, setFontSize] = useState<number>(() => readInitialTerminalFontSize());
|
||||
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||
const [stickyModifier, setStickyModifier] = useState<null | "ctrl" | "alt">(null);
|
||||
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
@@ -618,6 +670,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
hasInitialCommandRun.current = false;
|
||||
setError(null);
|
||||
setExitCode(null);
|
||||
setShowShortcuts(false);
|
||||
setStickyModifier(null);
|
||||
}, [isOpen]);
|
||||
|
||||
// Subscribe to terminal data.
|
||||
@@ -906,6 +960,37 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
refitTerminal();
|
||||
}, [refitTerminal]);
|
||||
|
||||
const toggleModifier = useCallback((modifier: "ctrl" | "alt") => {
|
||||
setStickyModifier((current) => (current === modifier ? null : modifier));
|
||||
}, []);
|
||||
|
||||
const sendShortcutKey = useCallback(
|
||||
(key: string) => {
|
||||
if (stickyModifier === "ctrl") {
|
||||
sendInput(ctrlChar(key));
|
||||
setStickyModifier(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stickyModifier === "alt") {
|
||||
sendInput(altChar(key));
|
||||
setStickyModifier(null);
|
||||
return;
|
||||
}
|
||||
|
||||
sendInput(key);
|
||||
},
|
||||
[sendInput, stickyModifier],
|
||||
);
|
||||
|
||||
const sendLiteralShortcut = useCallback(
|
||||
(value: string) => {
|
||||
sendInput(value);
|
||||
setStickyModifier(null);
|
||||
},
|
||||
[sendInput],
|
||||
);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getStatusIndicator = () => {
|
||||
@@ -1035,6 +1120,16 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
<Trash2 size={14} />
|
||||
<span className="terminal-action-label">Clear</span>
|
||||
</button>
|
||||
<button
|
||||
className="terminal-clear-btn"
|
||||
onClick={() => setShowShortcuts((current) => !current)}
|
||||
data-testid="terminal-shortcut-toggle"
|
||||
title="Shortcuts"
|
||||
aria-pressed={showShortcuts}
|
||||
>
|
||||
<Keyboard size={14} />
|
||||
<span className="terminal-action-label">Shortcuts</span>
|
||||
</button>
|
||||
<button
|
||||
className="terminal-close"
|
||||
onClick={onClose}
|
||||
@@ -1129,6 +1224,60 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showShortcuts && (
|
||||
<div className="terminal-shortcut-panel" data-testid="terminal-shortcut-panel">
|
||||
<div className="terminal-shortcut-modifier-row">
|
||||
<button
|
||||
type="button"
|
||||
className={`terminal-shortcut-btn terminal-shortcut-btn--modifier ${
|
||||
stickyModifier === "ctrl" ? "is-active" : ""
|
||||
}`}
|
||||
data-testid="terminal-modifier-ctrl"
|
||||
onClick={() => toggleModifier("ctrl")}
|
||||
aria-pressed={stickyModifier === "ctrl"}
|
||||
>
|
||||
Ctrl
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`terminal-shortcut-btn terminal-shortcut-btn--modifier ${
|
||||
stickyModifier === "alt" ? "is-active" : ""
|
||||
}`}
|
||||
data-testid="terminal-modifier-alt"
|
||||
onClick={() => toggleModifier("alt")}
|
||||
aria-pressed={stickyModifier === "alt"}
|
||||
>
|
||||
Alt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-shortcut-btn"
|
||||
onClick={() => sendLiteralShortcut("\x1b")}
|
||||
>
|
||||
ESC
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-shortcut-btn"
|
||||
onClick={() => sendLiteralShortcut("\t")}
|
||||
>
|
||||
Tab
|
||||
</button>
|
||||
</div>
|
||||
{SHORTCUT_KEYS.map((shortcut) => (
|
||||
<button
|
||||
key={shortcut.label}
|
||||
type="button"
|
||||
className="terminal-shortcut-btn"
|
||||
onClick={() => sendShortcutKey(shortcut.key)}
|
||||
title={shortcut.description}
|
||||
>
|
||||
{shortcut.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection status bar */}
|
||||
<div className="terminal-status-bar" data-testid="terminal-status-bar">
|
||||
<span className={`terminal-connection-status ${connectionStatus}`}>
|
||||
@@ -1166,7 +1315,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
</button>
|
||||
</span>
|
||||
<span className="terminal-shortcuts">
|
||||
Ctrl++/- zoom • Ctrl+L clear • Esc close
|
||||
Ctrl++/- zoom • ⌨ Shortcuts panel • Esc close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { TerminalModal, _resetInitialViewportHeight } from "../TerminalModal";
|
||||
import { TerminalModal, _resetInitialViewportHeight, ctrlChar, altChar } from "../TerminalModal";
|
||||
import * as useTerminalModule from "../../hooks/useTerminal";
|
||||
import * as useTerminalSessionsModule from "../../hooks/useTerminalSessions";
|
||||
import * as apiModule from "../../api";
|
||||
@@ -64,6 +64,18 @@ const mockCreateTerminalSession = vi.mocked(apiModule.createTerminalSession);
|
||||
const mockKillPtyTerminalSession = vi.mocked(apiModule.killPtyTerminalSession);
|
||||
const TERMINAL_FONT_SIZE_KEY = "kb-terminal-font-size";
|
||||
|
||||
describe("ctrlChar/altChar helpers", () => {
|
||||
it("maps Ctrl+C/D/Z/L and Alt sequences correctly", () => {
|
||||
expect(ctrlChar("c")).toBe("\x03");
|
||||
expect(ctrlChar("d")).toBe("\x04");
|
||||
expect(ctrlChar("z")).toBe("\x1a");
|
||||
expect(ctrlChar("l")).toBe("\x0c");
|
||||
|
||||
expect(altChar("c")).toBe("\x1bc");
|
||||
expect(altChar("[")).toBe("\x1b[");
|
||||
});
|
||||
});
|
||||
|
||||
// Default tab state
|
||||
const defaultTab = {
|
||||
id: "tab-1",
|
||||
@@ -519,6 +531,104 @@ describe("TerminalModal", () => {
|
||||
expect(mockTerminalInstance.open).toHaveBeenCalledWith(terminalDiv);
|
||||
});
|
||||
|
||||
describe("shortcut panel", () => {
|
||||
it("is hidden by default and toggles from header action", async () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.queryByTestId("terminal-shortcut-panel")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||||
expect(screen.getByTestId("terminal-shortcut-panel")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||||
expect(screen.queryByTestId("terminal-shortcut-panel")).toBeNull();
|
||||
});
|
||||
|
||||
it("supports sticky modifier toggle semantics", async () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||||
|
||||
const ctrlBtn = screen.getByTestId("terminal-modifier-ctrl");
|
||||
const altBtn = screen.getByTestId("terminal-modifier-alt");
|
||||
|
||||
fireEvent.click(ctrlBtn);
|
||||
expect(ctrlBtn.getAttribute("aria-pressed")).toBe("true");
|
||||
|
||||
fireEvent.click(ctrlBtn);
|
||||
expect(ctrlBtn.getAttribute("aria-pressed")).toBe("false");
|
||||
|
||||
fireEvent.click(ctrlBtn);
|
||||
fireEvent.click(altBtn);
|
||||
expect(ctrlBtn.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(altBtn.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("sends modified and literal keys, then clears sticky modifier", async () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||||
|
||||
const ctrlBtn = screen.getByTestId("terminal-modifier-ctrl");
|
||||
const altBtn = screen.getByTestId("terminal-modifier-alt");
|
||||
|
||||
fireEvent.click(ctrlBtn);
|
||||
fireEvent.click(screen.getByRole("button", { name: "C" }));
|
||||
expect(mockSendInput).toHaveBeenCalledWith("\x03");
|
||||
expect(ctrlBtn.getAttribute("aria-pressed")).toBe("false");
|
||||
|
||||
fireEvent.click(altBtn);
|
||||
fireEvent.click(screen.getByRole("button", { name: "D" }));
|
||||
expect(mockSendInput).toHaveBeenCalledWith("\x1bd");
|
||||
expect(altBtn.getAttribute("aria-pressed")).toBe("false");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Z" }));
|
||||
expect(mockSendInput).toHaveBeenCalledWith("z");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "ESC" }));
|
||||
expect(mockSendInput).toHaveBeenCalledWith("\x1b");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Tab" }));
|
||||
expect(mockSendInput).toHaveBeenCalledWith("\t");
|
||||
});
|
||||
|
||||
it("renders shortcut controls on mobile viewport", async () => {
|
||||
const previousInnerWidth = window.innerWidth;
|
||||
const previousOntouchstart = window.ontouchstart;
|
||||
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
value: 375,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "ontouchstart", {
|
||||
value: null,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||||
|
||||
expect(screen.getByTestId("terminal-shortcut-panel")).toBeTruthy();
|
||||
expect(screen.getByTestId("terminal-modifier-ctrl")).toBeTruthy();
|
||||
expect(screen.getByTestId("terminal-modifier-alt")).toBeTruthy();
|
||||
} finally {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
value: previousInnerWidth,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "ontouchstart", {
|
||||
value: previousOntouchstart,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("font size controls", () => {
|
||||
it("renders controls in the status bar with default font-size value", async () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
Reference in New Issue
Block a user