FN-7872: add custom terminal shortcut buttons to the Preferences panel
Lets users define, edit, and remove custom terminal shortcut buttons (label + injected key sequence) from the terminal Preferences panel, persisted client-side. - Add a customShortcuts list to terminalPreferences (kb-terminal-preferences localStorage) with add/edit/remove management - Add decodeTerminalShortcutSequence to decode \n, \t, \r, \e/\x1b, and \\ escapes for injected sequences - Render custom shortcut buttons in TerminalModal's shortcut panel, injecting via the focus-preserving sendLiteralShortcut path - Add management UI (add/edit/remove) for custom shortcuts in the terminal Preferences panel, styled in TerminalModal.css - Extend TerminalModal and terminalPreferences test coverage for the new custom shortcut behavior - Document custom terminal shortcuts in docs/dashboard-guide.md - Add a minor changeset for @runfusion/fusion Files changed: .changeset/fn-7872-terminal-custom-shortcuts.md | 7 + docs/dashboard-guide.md | 7 +- .../dashboard/app/components/TerminalModal.css | 106 +++++++++++ .../dashboard/app/components/TerminalModal.tsx | 202 ++++++++++++++++++++- .../components/__tests__/TerminalModal.test.tsx | 183 +++++++++++++++++++ .../utils/__tests__/terminalPreferences.test.ts | 68 +++++++ .../dashboard/app/utils/terminalPreferences.ts | 140 +++++++++++++- 7 files changed, 708 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-7872 Fusion-Task-Lineage: 9b2df0da-0eb7-4cec-a42b-767e23ff4c2c Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7872-terminal-custom-shortcuts.md
Normal file
7
.changeset/fn-7872-terminal-custom-shortcuts.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Let users define custom terminal shortcut buttons (label + injected sequence) from the terminal Preferences panel.
|
||||||
|
category: feature
|
||||||
|
dev: Adds a customShortcuts list to the client-local terminalPreferences (kb-terminal-preferences localStorage), a decodeTerminalShortcutSequence escape decoder (\n/\t/\r/\e/\x1b/\\), custom shortcut buttons in TerminalModal's shortcut panel injecting via the focus-preserving sendLiteralShortcut path, and add/edit/remove management UI in the preferences panel. Client-only; no server schema.
|
||||||
@@ -684,9 +684,10 @@ Features:
|
|||||||
- PTY-backed shell sessions
|
- PTY-backed shell sessions
|
||||||
- Ctrl/Cmd+C copies the current terminal selection, while plain Ctrl+C with no selection still sends SIGINT
|
- Ctrl/Cmd+C copies the current terminal selection, while plain Ctrl+C with no selection still sends SIGINT
|
||||||
- Ctrl/Cmd+V pastes clipboard text exactly once into the active integrated terminal or live embedded CLI session
|
- Ctrl/Cmd+V pastes clipboard text exactly once into the active integrated terminal or live embedded CLI session
|
||||||
- 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 Shortcuts panel includes Ctrl/Alt helpers, ESC/Tab, common shell shortcuts, Up/Down/Left/Right arrow buttons, and any custom shortcut buttons you define for keyboard-less shell history, line editing, or frequent snippets
|
||||||
- Shortcuts panel buttons preserve terminal focus on the active terminal session during pointer, mouse, and touch activation, so Ctrl combinations reliably emit control bytes to the shell
|
- Shortcuts panel buttons preserve terminal focus on the active terminal session during pointer, mouse, and touch activation, so Ctrl combinations and custom snippets reliably emit bytes to the shell
|
||||||
- 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
|
- The Preferences panel customizes font family, font size, cursor style, cursor blink, renderer, and custom shortcut buttons; changes persist in browser `localStorage` under `kb-terminal-preferences`, with the legacy `kb-terminal-font-size` value migrated automatically
|
||||||
|
- Custom shortcuts have a short label and injected value. Use `\n` for Enter, `\t` for Tab, `\r` for Return, `\e` or `\x1b` for Esc, and `\\` for a literal backslash; unknown escapes are sent literally. Add, edit, or remove them from the terminal Preferences panel, and reset terminal preferences to clear them.
|
||||||
- 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
|
- 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 and physical copy/paste semantics for live interactive session views: selected text copies with the platform copy modifier, no-selection Ctrl+C stays available to the shell, and Ctrl/Cmd+V sends clipboard text exactly once to the attach channel. Idle, ended, and read-only replay views suppress input handlers and mobile accessory controls. 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.
|
- Embedded CLI session terminals honor the same saved preferences and physical copy/paste semantics for live interactive session views: selected text copies with the platform copy modifier, no-selection Ctrl+C stays available to the shell, and Ctrl/Cmd+V sends clipboard text exactly once to the attach channel. Idle, ended, and read-only replay views suppress input handlers and mobile accessory controls. 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
|
- Mobile-aware virtual keyboard handling and auto-refit behavior
|
||||||
|
|||||||
@@ -1310,6 +1310,100 @@ The shortcut bar (modifier keys + arrow keys) must sit on ONE line, not stack in
|
|||||||
justify-self: start;
|
justify-self: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.terminal-shortcut-btn--custom {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-custom-shortcuts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
padding: var(--space-sm);
|
||||||
|
border: var(--btn-border-width) solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-custom-shortcuts__header,
|
||||||
|
.terminal-custom-shortcuts__row,
|
||||||
|
.terminal-custom-shortcuts__form,
|
||||||
|
.terminal-custom-shortcuts__form-actions,
|
||||||
|
.terminal-custom-shortcuts__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-custom-shortcuts__header {
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-custom-shortcuts__header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-custom-shortcuts__count,
|
||||||
|
.terminal-custom-shortcuts__empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-custom-shortcuts__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-custom-shortcuts__row {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-xs);
|
||||||
|
border: var(--btn-border-width) solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-custom-shortcuts__summary {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-custom-shortcuts__summary code {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-custom-shortcuts__actions,
|
||||||
|
.terminal-custom-shortcuts__form-actions {
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-custom-shortcuts__form {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-custom-shortcuts__form .terminal-preference-field {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-custom-shortcuts__submit {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-connection-status {
|
.terminal-connection-status {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -1728,6 +1822,18 @@ The Android keyboard-open recurrence can start with a touch-primary visualViewpo
|
|||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.terminal-custom-shortcuts__header,
|
||||||
|
.terminal-custom-shortcuts__row,
|
||||||
|
.terminal-custom-shortcuts__form,
|
||||||
|
.terminal-custom-shortcuts__form-actions {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-custom-shortcuts__actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-font-size-btn {
|
.terminal-font-size-btn {
|
||||||
min-width: calc(var(--space-xl) + var(--space-md));
|
min-width: calc(var(--space-xl) + var(--space-md));
|
||||||
min-height: calc(var(--space-xl) + var(--space-md));
|
min-height: calc(var(--space-xl) + var(--space-md));
|
||||||
|
|||||||
@@ -37,17 +37,24 @@ import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack";
|
|||||||
import { getPathBasename } from "../utils/pathDisplay";
|
import { getPathBasename } from "../utils/pathDisplay";
|
||||||
import {
|
import {
|
||||||
DEFAULT_TERMINAL_PREFERENCES,
|
DEFAULT_TERMINAL_PREFERENCES,
|
||||||
|
MAX_TERMINAL_CUSTOM_SHORTCUTS,
|
||||||
|
MAX_TERMINAL_CUSTOM_SHORTCUT_LABEL_LENGTH,
|
||||||
|
MAX_TERMINAL_CUSTOM_SHORTCUT_VALUE_LENGTH,
|
||||||
MAX_TERMINAL_FONT_SIZE,
|
MAX_TERMINAL_FONT_SIZE,
|
||||||
MIN_TERMINAL_FONT_SIZE,
|
MIN_TERMINAL_FONT_SIZE,
|
||||||
TERMINAL_FONT_FAMILY_PRESETS,
|
TERMINAL_FONT_FAMILY_PRESETS,
|
||||||
clampTerminalFontSize,
|
clampTerminalFontSize,
|
||||||
|
createTerminalCustomShortcutId,
|
||||||
|
decodeTerminalShortcutSequence,
|
||||||
forceTerminalFontRemeasure,
|
forceTerminalFontRemeasure,
|
||||||
|
normalizeTerminalCustomShortcuts,
|
||||||
readTerminalPreferences,
|
readTerminalPreferences,
|
||||||
resolveTerminalFontFamily,
|
resolveTerminalFontFamily,
|
||||||
resolveTerminalGlyphFontFamily,
|
resolveTerminalGlyphFontFamily,
|
||||||
waitForTerminalFontMetrics,
|
waitForTerminalFontMetrics,
|
||||||
withDomBasedTerminalCharacterMeasurement,
|
withDomBasedTerminalCharacterMeasurement,
|
||||||
writeTerminalPreferences,
|
writeTerminalPreferences,
|
||||||
|
type TerminalCustomShortcut,
|
||||||
type TerminalPreferences,
|
type TerminalPreferences,
|
||||||
type TerminalRenderer,
|
type TerminalRenderer,
|
||||||
} from "../utils/terminalPreferences";
|
} from "../utils/terminalPreferences";
|
||||||
@@ -551,6 +558,9 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
|||||||
const [terminalPreferences, setTerminalPreferences] = useState<TerminalPreferences>(() =>
|
const [terminalPreferences, setTerminalPreferences] = useState<TerminalPreferences>(() =>
|
||||||
readTerminalPreferences(),
|
readTerminalPreferences(),
|
||||||
);
|
);
|
||||||
|
const [customShortcutLabel, setCustomShortcutLabel] = useState("");
|
||||||
|
const [customShortcutValue, setCustomShortcutValue] = useState("");
|
||||||
|
const [editingCustomShortcutId, setEditingCustomShortcutId] = useState<string | null>(null);
|
||||||
const fontSize = terminalPreferences.fontSize;
|
const fontSize = terminalPreferences.fontSize;
|
||||||
const resolvedFontFamily = resolveTerminalFontFamily(terminalPreferences.fontFamily);
|
const resolvedFontFamily = resolveTerminalFontFamily(terminalPreferences.fontFamily);
|
||||||
/*
|
/*
|
||||||
@@ -1325,6 +1335,78 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
|||||||
setTerminalPreferences((current) => writeTerminalPreferences({ ...current, ...patch }));
|
setTerminalPreferences((current) => writeTerminalPreferences({ ...current, ...patch }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const resetCustomShortcutForm = useCallback(() => {
|
||||||
|
setCustomShortcutLabel("");
|
||||||
|
setCustomShortcutValue("");
|
||||||
|
setEditingCustomShortcutId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const persistCustomShortcuts = useCallback(
|
||||||
|
(shortcuts: TerminalCustomShortcut[]) => {
|
||||||
|
updateTerminalPreferences({
|
||||||
|
customShortcuts: normalizeTerminalCustomShortcuts(shortcuts),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateTerminalPreferences],
|
||||||
|
);
|
||||||
|
|
||||||
|
const startEditingCustomShortcut = useCallback((shortcut: TerminalCustomShortcut) => {
|
||||||
|
setCustomShortcutLabel(shortcut.label);
|
||||||
|
setCustomShortcutValue(shortcut.value);
|
||||||
|
setEditingCustomShortcutId(shortcut.id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeCustomShortcut = useCallback(
|
||||||
|
(shortcutId: string) => {
|
||||||
|
persistCustomShortcuts(
|
||||||
|
terminalPreferences.customShortcuts.filter((shortcut) => shortcut.id !== shortcutId),
|
||||||
|
);
|
||||||
|
if (editingCustomShortcutId === shortcutId) {
|
||||||
|
resetCustomShortcutForm();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editingCustomShortcutId, persistCustomShortcuts, resetCustomShortcutForm, terminalPreferences.customShortcuts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const trimmedCustomShortcutLabel = customShortcutLabel.trim();
|
||||||
|
const trimmedCustomShortcutValue = customShortcutValue.trim();
|
||||||
|
const isEditingCustomShortcut = editingCustomShortcutId !== null;
|
||||||
|
const customShortcutLimitReached =
|
||||||
|
terminalPreferences.customShortcuts.length >= MAX_TERMINAL_CUSTOM_SHORTCUTS;
|
||||||
|
const canSubmitCustomShortcut =
|
||||||
|
trimmedCustomShortcutLabel !== "" &&
|
||||||
|
trimmedCustomShortcutValue !== "" &&
|
||||||
|
(isEditingCustomShortcut || !customShortcutLimitReached);
|
||||||
|
|
||||||
|
const submitCustomShortcut = useCallback(() => {
|
||||||
|
if (!canSubmitCustomShortcut) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextShortcut: TerminalCustomShortcut = {
|
||||||
|
id: editingCustomShortcutId ?? createTerminalCustomShortcutId(),
|
||||||
|
label: trimmedCustomShortcutLabel,
|
||||||
|
value: trimmedCustomShortcutValue,
|
||||||
|
};
|
||||||
|
const nextShortcuts = isEditingCustomShortcut
|
||||||
|
? terminalPreferences.customShortcuts.map((shortcut) =>
|
||||||
|
shortcut.id === editingCustomShortcutId ? nextShortcut : shortcut,
|
||||||
|
)
|
||||||
|
: [...terminalPreferences.customShortcuts, nextShortcut];
|
||||||
|
|
||||||
|
persistCustomShortcuts(nextShortcuts);
|
||||||
|
resetCustomShortcutForm();
|
||||||
|
}, [
|
||||||
|
canSubmitCustomShortcut,
|
||||||
|
editingCustomShortcutId,
|
||||||
|
isEditingCustomShortcut,
|
||||||
|
persistCustomShortcuts,
|
||||||
|
resetCustomShortcutForm,
|
||||||
|
terminalPreferences.customShortcuts,
|
||||||
|
trimmedCustomShortcutLabel,
|
||||||
|
trimmedCustomShortcutValue,
|
||||||
|
]);
|
||||||
|
|
||||||
const setFontSize = useCallback(
|
const setFontSize = useCallback(
|
||||||
(value: number | ((current: number) => number)) => {
|
(value: number | ((current: number) => number)) => {
|
||||||
setTerminalPreferences((current) => {
|
setTerminalPreferences((current) => {
|
||||||
@@ -1341,7 +1423,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
|||||||
|
|
||||||
const resetTerminalPreferences = useCallback(() => {
|
const resetTerminalPreferences = useCallback(() => {
|
||||||
setTerminalPreferences(writeTerminalPreferences(DEFAULT_TERMINAL_PREFERENCES));
|
setTerminalPreferences(writeTerminalPreferences(DEFAULT_TERMINAL_PREFERENCES));
|
||||||
}, []);
|
resetCustomShortcutForm();
|
||||||
|
}, [resetCustomShortcutForm]);
|
||||||
|
|
||||||
const refitTerminal = useCallback(() => {
|
const refitTerminal = useCallback(() => {
|
||||||
const terminal = xtermRef.current;
|
const terminal = xtermRef.current;
|
||||||
@@ -2940,6 +3023,26 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
|||||||
{shortcut.label}
|
{shortcut.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{/**
|
||||||
|
* FNXC:Terminal 2026-07-12-00:00:
|
||||||
|
* FN-7872 user-defined shortcuts must inject their decoded value through the same sendLiteralShortcut path as built-in literal shortcuts. Keep the pointer/mouse/touch focus guards so the FN-6697/FN-6737 xterm-refocus invariant holds for custom buttons on desktop and touch surfaces.
|
||||||
|
*/}
|
||||||
|
{terminalPreferences.customShortcuts.map((shortcut) => (
|
||||||
|
<button
|
||||||
|
key={shortcut.id}
|
||||||
|
type="button"
|
||||||
|
className="terminal-shortcut-btn terminal-shortcut-btn--custom"
|
||||||
|
data-testid={`terminal-custom-shortcut-${shortcut.id}`}
|
||||||
|
title={shortcut.label}
|
||||||
|
aria-label={shortcut.label}
|
||||||
|
onPointerDown={preserveShortcutFocus}
|
||||||
|
onMouseDown={preserveShortcutFocus}
|
||||||
|
onTouchStart={preserveShortcutFocus}
|
||||||
|
onClick={() => sendLiteralShortcut(decodeTerminalShortcutSequence(shortcut.value))}
|
||||||
|
>
|
||||||
|
{shortcut.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -3025,6 +3128,103 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
<section className="terminal-custom-shortcuts" data-testid="terminal-custom-shortcuts">
|
||||||
|
<div className="terminal-custom-shortcuts__header">
|
||||||
|
<div>
|
||||||
|
<h3>{t("terminal.customShortcutsTitle", "Custom shortcuts")}</h3>
|
||||||
|
<p className="terminal-preference-note">
|
||||||
|
{t(
|
||||||
|
"terminal.customShortcutsHelp",
|
||||||
|
"Use \\n for Enter, \\t for Tab, \\e or \\x1b for Esc, \\r for Return, and \\\\ for a literal backslash.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="terminal-custom-shortcuts__count">
|
||||||
|
{terminalPreferences.customShortcuts.length}/{MAX_TERMINAL_CUSTOM_SHORTCUTS}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{terminalPreferences.customShortcuts.length === 0 ? (
|
||||||
|
<p className="terminal-custom-shortcuts__empty" data-testid="terminal-custom-shortcuts-empty">
|
||||||
|
{t("terminal.customShortcutsEmpty", "No custom shortcuts yet.")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="terminal-custom-shortcuts__list" aria-label={t("terminal.customShortcutsList", "Custom terminal shortcuts")}>
|
||||||
|
{terminalPreferences.customShortcuts.map((shortcut) => (
|
||||||
|
<li key={shortcut.id} className="terminal-custom-shortcuts__row">
|
||||||
|
<span className="terminal-custom-shortcuts__summary">
|
||||||
|
<strong>{shortcut.label}</strong>
|
||||||
|
<code>{shortcut.value}</code>
|
||||||
|
</span>
|
||||||
|
<span className="terminal-custom-shortcuts__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
data-testid={`terminal-custom-shortcut-edit-${shortcut.id}`}
|
||||||
|
onClick={() => startEditingCustomShortcut(shortcut)}
|
||||||
|
>
|
||||||
|
{t("common.edit", "Edit")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
data-testid={`terminal-custom-shortcut-remove-${shortcut.id}`}
|
||||||
|
onClick={() => removeCustomShortcut(shortcut.id)}
|
||||||
|
>
|
||||||
|
{t("common.remove", "Remove")}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="terminal-custom-shortcuts__form">
|
||||||
|
<label className="terminal-preference-field">
|
||||||
|
<span>{t("terminal.customShortcutLabel", "Button label")}</span>
|
||||||
|
<input
|
||||||
|
className="input terminal-preference-control"
|
||||||
|
data-testid="terminal-custom-shortcut-label-input"
|
||||||
|
type="text"
|
||||||
|
maxLength={MAX_TERMINAL_CUSTOM_SHORTCUT_LABEL_LENGTH}
|
||||||
|
value={customShortcutLabel}
|
||||||
|
onChange={(event) => setCustomShortcutLabel(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="terminal-preference-field">
|
||||||
|
<span>{t("terminal.customShortcutValue", "Injected value")}</span>
|
||||||
|
<input
|
||||||
|
className="input terminal-preference-control"
|
||||||
|
data-testid="terminal-custom-shortcut-value-input"
|
||||||
|
type="text"
|
||||||
|
maxLength={MAX_TERMINAL_CUSTOM_SHORTCUT_VALUE_LENGTH}
|
||||||
|
value={customShortcutValue}
|
||||||
|
onChange={(event) => setCustomShortcutValue(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="terminal-custom-shortcuts__form-actions">
|
||||||
|
{isEditingCustomShortcut && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
data-testid="terminal-custom-shortcut-cancel"
|
||||||
|
onClick={resetCustomShortcutForm}
|
||||||
|
>
|
||||||
|
{t("common.cancel", "Cancel")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn terminal-custom-shortcuts__submit"
|
||||||
|
data-testid="terminal-custom-shortcut-add"
|
||||||
|
disabled={!canSubmitCustomShortcut}
|
||||||
|
onClick={submitCustomShortcut}
|
||||||
|
>
|
||||||
|
{isEditingCustomShortcut
|
||||||
|
? t("terminal.customShortcutSave", "Save shortcut")
|
||||||
|
: t("terminal.customShortcutAdd", "Add shortcut")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn terminal-preferences-reset"
|
className="btn terminal-preferences-reset"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { TerminalModal, _resetInitialViewportHeight, ctrlChar, altChar, evaluate
|
|||||||
import {
|
import {
|
||||||
DEFAULT_TERMINAL_PREFERENCES,
|
DEFAULT_TERMINAL_PREFERENCES,
|
||||||
LEGACY_TERMINAL_FONT_SIZE_KEY,
|
LEGACY_TERMINAL_FONT_SIZE_KEY,
|
||||||
|
MAX_TERMINAL_CUSTOM_SHORTCUTS,
|
||||||
TERMINAL_PREFERENCES_KEY,
|
TERMINAL_PREFERENCES_KEY,
|
||||||
TERMINAL_SYMBOLS_FONT_FAMILY,
|
TERMINAL_SYMBOLS_FONT_FAMILY,
|
||||||
XTERM_FONT_FAMILY,
|
XTERM_FONT_FAMILY,
|
||||||
@@ -2070,6 +2071,77 @@ describe("TerminalModal", () => {
|
|||||||
expect(screen.getByTestId("terminal-modifier-ctrl").getAttribute("aria-pressed")).toBe("false");
|
expect(screen.getByTestId("terminal-modifier-ctrl").getAttribute("aria-pressed")).toBe("false");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders persisted custom shortcuts and injects decoded values without stealing focus", async () => {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
TERMINAL_PREFERENCES_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
...DEFAULT_TERMINAL_PREFERENCES,
|
||||||
|
customShortcuts: [{ id: "cs-status", label: "Status", value: "git status\\n" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const terminalDiv = screen.getByTestId("terminal-xterm");
|
||||||
|
const helperTextarea = document.createElement("textarea");
|
||||||
|
helperTextarea.className = "xterm-helper-textarea";
|
||||||
|
const focusSpy = vi.spyOn(helperTextarea, "focus");
|
||||||
|
terminalDiv.appendChild(helperTextarea);
|
||||||
|
helperTextarea.focus();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||||||
|
const customButton = screen.getByTestId("terminal-custom-shortcut-cs-status");
|
||||||
|
expect(customButton).toHaveClass("terminal-shortcut-btn--custom");
|
||||||
|
|
||||||
|
const mouseDown = new MouseEvent("mousedown", { bubbles: true, cancelable: true });
|
||||||
|
customButton.dispatchEvent(mouseDown);
|
||||||
|
expect(mouseDown.defaultPrevented).toBe(true);
|
||||||
|
expect(document.activeElement).toBe(helperTextarea);
|
||||||
|
|
||||||
|
mockSendInput.mockClear();
|
||||||
|
mockTerminalInstance.focus.mockClear();
|
||||||
|
focusSpy.mockClear();
|
||||||
|
|
||||||
|
fireEvent.click(customButton);
|
||||||
|
|
||||||
|
expect(mockSendInput).toHaveBeenCalledWith("git status\n");
|
||||||
|
expect(mockTerminalInstance.focus).toHaveBeenCalled();
|
||||||
|
expect(focusSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders custom shortcut controls on mobile without empty shells", async () => {
|
||||||
|
const previousInnerWidth = window.innerWidth;
|
||||||
|
window.localStorage.setItem(
|
||||||
|
TERMINAL_PREFERENCES_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
...DEFAULT_TERMINAL_PREFERENCES,
|
||||||
|
customShortcuts: [{ id: "cs-mobile", label: "Clear", value: "clear\\n" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
Object.defineProperty(window, "innerWidth", {
|
||||||
|
value: 375,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||||||
|
fireEvent.click(screen.getByTestId("terminal-preferences-toggle"));
|
||||||
|
|
||||||
|
expect(screen.getByTestId("terminal-modal")).toHaveClass("terminal-modal--mobile");
|
||||||
|
expect(screen.getByTestId("terminal-custom-shortcut-cs-mobile")).toHaveTextContent("Clear");
|
||||||
|
expect(screen.getByTestId("terminal-custom-shortcuts")).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId("terminal-custom-shortcuts-empty")).toBeNull();
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(window, "innerWidth", {
|
||||||
|
value: previousInnerWidth,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("renders shortcut controls on mobile viewport", async () => {
|
it("renders shortcut controls on mobile viewport", async () => {
|
||||||
const previousInnerWidth = window.innerWidth;
|
const previousInnerWidth = window.innerWidth;
|
||||||
const previousOntouchstart = window.ontouchstart;
|
const previousOntouchstart = window.ontouchstart;
|
||||||
@@ -2326,6 +2398,117 @@ describe("TerminalModal", () => {
|
|||||||
expect(screen.getByTestId("terminal-renderer-reopen-note")).toBeTruthy();
|
expect(screen.getByTestId("terminal-renderer-reopen-note")).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adds and edits custom shortcuts through the shared preferences record", async () => {
|
||||||
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("terminal-preferences-toggle"));
|
||||||
|
expect(screen.getByTestId("terminal-custom-shortcuts-empty")).toBeTruthy();
|
||||||
|
const addButton = screen.getByTestId("terminal-custom-shortcut-add");
|
||||||
|
expect(addButton).toHaveProperty("disabled", true);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId("terminal-custom-shortcut-label-input"), {
|
||||||
|
target: { value: "Status" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByTestId("terminal-custom-shortcut-value-input"), {
|
||||||
|
target: { value: "git status\\n" },
|
||||||
|
});
|
||||||
|
expect(addButton).toHaveProperty("disabled", false);
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const persisted = JSON.parse(window.localStorage.getItem(TERMINAL_PREFERENCES_KEY) ?? "null");
|
||||||
|
expect(persisted.customShortcuts).toEqual([
|
||||||
|
expect.objectContaining({ label: "Status", value: "git status\\n" }),
|
||||||
|
]);
|
||||||
|
const shortcutId = persisted.customShortcuts[0].id;
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||||||
|
expect(screen.getByTestId(`terminal-custom-shortcut-${shortcutId}`)).toHaveTextContent("Status");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId(`terminal-custom-shortcut-edit-${shortcutId}`));
|
||||||
|
fireEvent.change(screen.getByTestId("terminal-custom-shortcut-label-input"), {
|
||||||
|
target: { value: "Clear" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByTestId("terminal-custom-shortcut-value-input"), {
|
||||||
|
target: { value: "clear\\n" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByTestId("terminal-custom-shortcut-add"));
|
||||||
|
|
||||||
|
const edited = JSON.parse(window.localStorage.getItem(TERMINAL_PREFERENCES_KEY) ?? "null");
|
||||||
|
expect(edited.customShortcuts).toEqual([
|
||||||
|
{ id: shortcutId, label: "Clear", value: "clear\\n" },
|
||||||
|
]);
|
||||||
|
expect(screen.getByTestId(`terminal-custom-shortcut-${shortcutId}`)).toHaveTextContent("Clear");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes custom shortcuts and reset to defaults clears them without leftover shells", async () => {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
TERMINAL_PREFERENCES_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
...DEFAULT_TERMINAL_PREFERENCES,
|
||||||
|
customShortcuts: [{ id: "cs-clear", label: "Clear", value: "clear\\n" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("terminal-preferences-toggle"));
|
||||||
|
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||||||
|
expect(screen.getByTestId("terminal-custom-shortcut-cs-clear")).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("terminal-custom-shortcut-remove-cs-clear"));
|
||||||
|
expect(screen.queryByTestId("terminal-custom-shortcut-cs-clear")).toBeNull();
|
||||||
|
expect(screen.getByTestId("terminal-custom-shortcuts-empty")).toBeTruthy();
|
||||||
|
expect(document.querySelectorAll(".terminal-custom-shortcuts__row")).toHaveLength(0);
|
||||||
|
expect(
|
||||||
|
JSON.parse(window.localStorage.getItem(TERMINAL_PREFERENCES_KEY) ?? "null").customShortcuts,
|
||||||
|
).toEqual([]);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId("terminal-custom-shortcut-label-input"), {
|
||||||
|
target: { value: "Again" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByTestId("terminal-custom-shortcut-value-input"), {
|
||||||
|
target: { value: "echo again\\n" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByTestId("terminal-custom-shortcut-add"));
|
||||||
|
expect(document.querySelectorAll(".terminal-custom-shortcuts__row")).toHaveLength(1);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("terminal-preferences-reset"));
|
||||||
|
expect(screen.getByTestId("terminal-custom-shortcuts-empty")).toBeTruthy();
|
||||||
|
expect(document.querySelectorAll(".terminal-custom-shortcuts__row")).toHaveLength(0);
|
||||||
|
expect(JSON.parse(window.localStorage.getItem(TERMINAL_PREFERENCES_KEY) ?? "null")).toEqual(
|
||||||
|
DEFAULT_TERMINAL_PREFERENCES,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables custom shortcut add when inputs are empty or the cap is reached", async () => {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
TERMINAL_PREFERENCES_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
...DEFAULT_TERMINAL_PREFERENCES,
|
||||||
|
customShortcuts: Array.from({ length: MAX_TERMINAL_CUSTOM_SHORTCUTS }, (_, index) => ({
|
||||||
|
id: `cs-${index}`,
|
||||||
|
label: `S${index}`,
|
||||||
|
value: `echo ${index}`,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("terminal-preferences-toggle"));
|
||||||
|
const addButton = screen.getByTestId("terminal-custom-shortcut-add");
|
||||||
|
expect(addButton).toHaveProperty("disabled", true);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId("terminal-custom-shortcut-label-input"), {
|
||||||
|
target: { value: "Overflow" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByTestId("terminal-custom-shortcut-value-input"), {
|
||||||
|
target: { value: "echo overflow\\n" },
|
||||||
|
});
|
||||||
|
expect(addButton).toHaveProperty("disabled", true);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("terminal-custom-shortcut-edit-cs-0"));
|
||||||
|
expect(addButton).toHaveProperty("disabled", false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("xterm container is rendered (visible under loading overlay) while loading", async () => {
|
it("xterm container is rendered (visible under loading overlay) while loading", async () => {
|
||||||
|
|||||||
@@ -2,9 +2,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import {
|
import {
|
||||||
DEFAULT_TERMINAL_PREFERENCES,
|
DEFAULT_TERMINAL_PREFERENCES,
|
||||||
LEGACY_TERMINAL_FONT_SIZE_KEY,
|
LEGACY_TERMINAL_FONT_SIZE_KEY,
|
||||||
|
MAX_TERMINAL_CUSTOM_SHORTCUT_LABEL_LENGTH,
|
||||||
|
MAX_TERMINAL_CUSTOM_SHORTCUT_VALUE_LENGTH,
|
||||||
|
MAX_TERMINAL_CUSTOM_SHORTCUTS,
|
||||||
TERMINAL_PREFERENCES_KEY,
|
TERMINAL_PREFERENCES_KEY,
|
||||||
XTERM_FONT_FAMILY,
|
XTERM_FONT_FAMILY,
|
||||||
|
decodeTerminalShortcutSequence,
|
||||||
forceTerminalFontRemeasure,
|
forceTerminalFontRemeasure,
|
||||||
|
normalizeTerminalCustomShortcuts,
|
||||||
readTerminalPreferences,
|
readTerminalPreferences,
|
||||||
waitForTerminalFontMetrics,
|
waitForTerminalFontMetrics,
|
||||||
writeTerminalPreferences,
|
writeTerminalPreferences,
|
||||||
@@ -81,6 +86,7 @@ describe("terminalPreferences", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(written).toEqual({
|
expect(written).toEqual({
|
||||||
|
...DEFAULT_TERMINAL_PREFERENCES,
|
||||||
fontFamily: "system-mono",
|
fontFamily: "system-mono",
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
cursorStyle: "underline",
|
cursorStyle: "underline",
|
||||||
@@ -91,6 +97,68 @@ describe("terminalPreferences", () => {
|
|||||||
expect(localStorage.getItem(LEGACY_TERMINAL_FONT_SIZE_KEY)).toBe("22");
|
expect(localStorage.getItem(LEGACY_TERMINAL_FONT_SIZE_KEY)).toBe("22");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes missing, corrupt, and non-array custom shortcuts to an empty list", () => {
|
||||||
|
expect(readTerminalPreferences().customShortcuts).toEqual([]);
|
||||||
|
|
||||||
|
localStorage.setItem(
|
||||||
|
TERMINAL_PREFERENCES_KEY,
|
||||||
|
JSON.stringify({ ...DEFAULT_TERMINAL_PREFERENCES, customShortcuts: "bad" }),
|
||||||
|
);
|
||||||
|
expect(readTerminalPreferences().customShortcuts).toEqual([]);
|
||||||
|
|
||||||
|
expect(normalizeTerminalCustomShortcuts(null)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops invalid custom shortcuts and caps labels, values, ids, and list length", () => {
|
||||||
|
const longLabel = ` ${"L".repeat(MAX_TERMINAL_CUSTOM_SHORTCUT_LABEL_LENGTH + 5)} `;
|
||||||
|
const longValue = ` ${"v".repeat(MAX_TERMINAL_CUSTOM_SHORTCUT_VALUE_LENGTH + 5)} `;
|
||||||
|
const normalized = normalizeTerminalCustomShortcuts([
|
||||||
|
null,
|
||||||
|
{ id: "keep", label: " Status ", value: " git status\\n " },
|
||||||
|
{ id: "empty-label", label: " ", value: "echo nope" },
|
||||||
|
{ id: "empty-value", label: "Nope", value: " " },
|
||||||
|
{ id: "keep", label: "Duplicate", value: "pwd\\n" },
|
||||||
|
{ label: longLabel, value: longValue },
|
||||||
|
...Array.from({ length: MAX_TERMINAL_CUSTOM_SHORTCUTS + 5 }, (_, index) => ({
|
||||||
|
id: `extra-${index}`,
|
||||||
|
label: `E${index}`,
|
||||||
|
value: `echo ${index}`,
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(normalized).toHaveLength(MAX_TERMINAL_CUSTOM_SHORTCUTS);
|
||||||
|
expect(normalized[0]).toEqual({ id: "keep", label: "Status", value: "git status\\n" });
|
||||||
|
expect(normalized[1]?.id).not.toBe("keep");
|
||||||
|
expect(new Set(normalized.map((shortcut) => shortcut.id)).size).toBe(normalized.length);
|
||||||
|
expect(normalized[2]?.label).toHaveLength(MAX_TERMINAL_CUSTOM_SHORTCUT_LABEL_LENGTH);
|
||||||
|
expect(normalized[2]?.value).toHaveLength(MAX_TERMINAL_CUSTOM_SHORTCUT_VALUE_LENGTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips valid custom shortcuts through the existing preferences record", () => {
|
||||||
|
const written = writeTerminalPreferences({
|
||||||
|
customShortcuts: [
|
||||||
|
{ id: "cs-status", label: "Status", value: "git status\\n" },
|
||||||
|
{ id: "cs-clear", label: "Clear", value: "clear\\n" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(written.customShortcuts).toEqual([
|
||||||
|
{ id: "cs-status", label: "Status", value: "git status\\n" },
|
||||||
|
{ id: "cs-clear", label: "Clear", value: "clear\\n" },
|
||||||
|
]);
|
||||||
|
expect(readTerminalPreferences().customShortcuts).toEqual(written.customShortcuts);
|
||||||
|
expect(JSON.parse(localStorage.getItem(TERMINAL_PREFERENCES_KEY) ?? "null")).toEqual(written);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes terminal shortcut escape sequences and preserves unknown escapes", () => {
|
||||||
|
expect(decodeTerminalShortcutSequence("git status\\n")).toBe("git status\n");
|
||||||
|
expect(decodeTerminalShortcutSequence("col1\\tcol2\\r")).toBe("col1\tcol2\r");
|
||||||
|
expect(decodeTerminalShortcutSequence("esc=\\e alt=\\x1b")).toBe("esc=\x1b alt=\x1b");
|
||||||
|
expect(decodeTerminalShortcutSequence("literal=\\\\ unknown=\\q tail=\\")).toBe(
|
||||||
|
"literal=\\ unknown=\\q tail=\\",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps terminal font metrics wait best-effort when iOS rejects the full stack shorthand", async () => {
|
it("keeps terminal font metrics wait best-effort when iOS rejects the full stack shorthand", async () => {
|
||||||
let readyAwaited = false;
|
let readyAwaited = false;
|
||||||
const load = vi.fn((font: string) => {
|
const load = vi.fn((font: string) => {
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ export const LEGACY_TERMINAL_FONT_SIZE_KEY = "kb-terminal-font-size";
|
|||||||
export const DEFAULT_TERMINAL_FONT_SIZE = 14;
|
export const DEFAULT_TERMINAL_FONT_SIZE = 14;
|
||||||
export const MIN_TERMINAL_FONT_SIZE = 8;
|
export const MIN_TERMINAL_FONT_SIZE = 8;
|
||||||
export const MAX_TERMINAL_FONT_SIZE = 32;
|
export const MAX_TERMINAL_FONT_SIZE = 32;
|
||||||
|
export const MAX_TERMINAL_CUSTOM_SHORTCUTS = 24;
|
||||||
|
export const MAX_TERMINAL_CUSTOM_SHORTCUT_LABEL_LENGTH = 24;
|
||||||
|
export const MAX_TERMINAL_CUSTOM_SHORTCUT_VALUE_LENGTH = 256;
|
||||||
|
|
||||||
export const TERMINAL_SYMBOLS_FONT_FAMILY = '"Fusion Terminal Nerd Font Symbols"';
|
export const TERMINAL_SYMBOLS_FONT_FAMILY = '"Fusion Terminal Nerd Font Symbols"';
|
||||||
|
|
||||||
@@ -40,17 +43,27 @@ export type TerminalFontFamily = (typeof TERMINAL_FONT_FAMILY_PRESETS)[number]["
|
|||||||
export type TerminalCursorStyle = "block" | "underline" | "bar";
|
export type TerminalCursorStyle = "block" | "underline" | "bar";
|
||||||
export type TerminalRenderer = "auto" | "canvas";
|
export type TerminalRenderer = "auto" | "canvas";
|
||||||
|
|
||||||
|
export interface TerminalCustomShortcut {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TerminalPreferences {
|
export interface TerminalPreferences {
|
||||||
fontFamily: TerminalFontFamily;
|
fontFamily: TerminalFontFamily;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
cursorStyle: TerminalCursorStyle;
|
cursorStyle: TerminalCursorStyle;
|
||||||
cursorBlink: boolean;
|
cursorBlink: boolean;
|
||||||
renderer: TerminalRenderer;
|
renderer: TerminalRenderer;
|
||||||
|
customShortcuts: TerminalCustomShortcut[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:Terminal 2026-06-16-23:35:
|
FNXC:Terminal 2026-06-16-23:35:
|
||||||
Terminal preferences are intentionally client-local: users can customize font, cursor, and renderer without introducing server settings schema. Reads must tolerate unavailable storage, corrupt JSON, unknown enum values, and legacy font-size data so opening the terminal never throws and always falls back to safe defaults.
|
Terminal preferences are intentionally client-local: users can customize font, cursor, renderer, and custom shortcut buttons without introducing server settings schema. Reads must tolerate unavailable storage, corrupt JSON, unknown enum values, legacy font-size data, and malformed shortcut lists so opening the terminal never throws and always falls back to safe defaults.
|
||||||
|
|
||||||
|
FNXC:Terminal 2026-07-12-00:00:
|
||||||
|
FN-7872 custom shortcuts remain client-local in kb-terminal-preferences, are count/length-capped, and are defensively normalized before use so corrupt localStorage cannot break terminal startup. decodeTerminalShortcutSequence is the single injection-decoding boundary for user-authored shortcut sequences before TerminalModal sends bytes to the PTY.
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_TERMINAL_PREFERENCES: TerminalPreferences = {
|
export const DEFAULT_TERMINAL_PREFERENCES: TerminalPreferences = {
|
||||||
fontFamily: "nerd-font",
|
fontFamily: "nerd-font",
|
||||||
@@ -58,6 +71,7 @@ export const DEFAULT_TERMINAL_PREFERENCES: TerminalPreferences = {
|
|||||||
cursorStyle: "block",
|
cursorStyle: "block",
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
renderer: "auto",
|
renderer: "auto",
|
||||||
|
customShortcuts: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function clampTerminalFontSize(value: number): number {
|
export function clampTerminalFontSize(value: number): number {
|
||||||
@@ -313,6 +327,129 @@ function isTerminalRenderer(value: unknown): value is TerminalRenderer {
|
|||||||
return value === "auto" || value === "canvas";
|
return value === "auto" || value === "canvas";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let terminalCustomShortcutIdCounter = 0;
|
||||||
|
|
||||||
|
export function createTerminalCustomShortcutId(): string {
|
||||||
|
const maybeCrypto = globalThis.crypto as { randomUUID?: () => string } | undefined;
|
||||||
|
if (typeof maybeCrypto?.randomUUID === "function") {
|
||||||
|
return `cs-${maybeCrypto.randomUUID()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
terminalCustomShortcutIdCounter += 1;
|
||||||
|
return `cs-${terminalCustomShortcutIdCounter.toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function capTerminalShortcutText(value: string, maxLength: number): string {
|
||||||
|
return value.trim().slice(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTerminalCustomShortcuts(
|
||||||
|
value: unknown,
|
||||||
|
): TerminalCustomShortcut[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized: TerminalCustomShortcut[] = [];
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const entry of value) {
|
||||||
|
if (normalized.length >= MAX_TERMINAL_CUSTOM_SHORTCUTS) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isObject(entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label =
|
||||||
|
typeof entry.label === "string"
|
||||||
|
? capTerminalShortcutText(entry.label, MAX_TERMINAL_CUSTOM_SHORTCUT_LABEL_LENGTH)
|
||||||
|
: "";
|
||||||
|
const shortcutValue =
|
||||||
|
typeof entry.value === "string"
|
||||||
|
? capTerminalShortcutText(entry.value, MAX_TERMINAL_CUSTOM_SHORTCUT_VALUE_LENGTH)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (!label || !shortcutValue) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawId = typeof entry.id === "string" ? entry.id.trim() : "";
|
||||||
|
let id = rawId && !seenIds.has(rawId) ? rawId : createTerminalCustomShortcutId();
|
||||||
|
if (seenIds.has(id)) {
|
||||||
|
const idBase = id;
|
||||||
|
let suffix = 2;
|
||||||
|
while (seenIds.has(`${idBase}-${suffix}`)) {
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
id = `${idBase}-${suffix}`;
|
||||||
|
}
|
||||||
|
seenIds.add(id);
|
||||||
|
normalized.push({ id, label, value: shortcutValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeTerminalShortcutSequence(value: string): string {
|
||||||
|
let decoded = "";
|
||||||
|
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
const character = value[index];
|
||||||
|
if (character !== "\\") {
|
||||||
|
decoded += character;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = value[index + 1];
|
||||||
|
if (next === undefined) {
|
||||||
|
decoded += character;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (next) {
|
||||||
|
case "n":
|
||||||
|
decoded += "\n";
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
case "t":
|
||||||
|
decoded += "\t";
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
case "r":
|
||||||
|
decoded += "\r";
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
case "e":
|
||||||
|
decoded += "\x1b";
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
case "\\":
|
||||||
|
decoded += "\\";
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
case "x": {
|
||||||
|
const hexEscape = value.slice(index + 1, index + 4).toLowerCase();
|
||||||
|
if (hexEscape === "x1b") {
|
||||||
|
decoded += "\x1b";
|
||||||
|
index += 3;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
decoded += `\\${next}`;
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
decoded += `\\${next}`;
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
function readLegacyFontSize(): number | undefined {
|
function readLegacyFontSize(): number | undefined {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -362,6 +499,7 @@ function normalizeTerminalPreferences(value: unknown): TerminalPreferences {
|
|||||||
renderer: isTerminalRenderer(source.renderer)
|
renderer: isTerminalRenderer(source.renderer)
|
||||||
? source.renderer
|
? source.renderer
|
||||||
: DEFAULT_TERMINAL_PREFERENCES.renderer,
|
: DEFAULT_TERMINAL_PREFERENCES.renderer,
|
||||||
|
customShortcuts: normalizeTerminalCustomShortcuts(source.customShortcuts),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user