Adds a standalone Keyboard Shortcuts settings section with an easier-to-use capture input and support for more shortcut actions. - Introduce a new KeyboardShortcutsSection with a dedicated ShortcutCaptureInput component for recording key combos - Split keyboard-shortcut settings out of the general settings section into their own settings tab - Extend the shortcut schema/types and useDashboardKeyboardShortcuts hook to support additional actions - Update settings save-split, section-keys, and defaults/parity tests to cover the new section - Update dashboard docs and add a changeset for the new settings section Files changed: .changeset/fn-7553-keyboard-shortcuts-section.md | 7 + docs/dashboard-guide.md | 22 ++- packages/core/src/__tests__/global-settings.test.ts | 9 +- packages/core/src/__tests__/settings-defaults.test.ts | 4 + packages/core/src/__tests__/settings-parity.test.ts | 2 +- packages/core/src/settings-schema.ts | 6 +- packages/core/src/types.ts | 12 ++ packages/dashboard/app/App.tsx | 26 +++- packages/dashboard/app/__tests__/App.keyboard-shortcuts.test.tsx | 47 ++++++- packages/dashboard/app/components/SettingsModal.css | 56 ++++++-- packages/dashboard/app/components/SettingsModal.tsx | 30 +++- packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx | 63 --------- packages/dashboard/app/components/__tests__/SettingsModal.keyboardShortcuts.test.tsx | 156 +++++++++++++++++++++ packages/dashboard/app/components/settings/__tests__/section-keys.test.ts | 1 + packages/dashboard/app/components/settings/save-split.ts | 6 +- packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx | 38 ----- packages/dashboard/app/components/settings/sections/KeyboardShortcutsSection.tsx | 78 +++++++++++ packages/dashboard/app/components/settings/sections/ShortcutCaptureInput.tsx | 118 ++++++++++++++++ packages/dashboard/app/components/settings/sections/__tests__/KeyboardShortcutsSection.test.tsx | 113 +++++++++++++++ packages/dashboard/app/hooks/__tests__/useDashboardKeyboardShortcuts.test.tsx | 73 +++++++++- packages/dashboard/app/hooks/useDashboardKeyboardShortcuts.ts | 38 ++++- packages/dashboard/app/utils/__tests__/keyboardShortcuts.test.ts | 37 ++++- packages/dashboard/app/utils/keyboardShortcuts.ts | 54 ++++++- 23 files changed, 854 insertions(+), 142 deletions(-) Fusion-Task-Id: FN-7553 Fusion-Task-Lineage: 51c1ad69-fbd3-45ce-8d2d-45d9962d7b76 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
220 lines
10 KiB
TypeScript
220 lines
10 KiB
TypeScript
/*
|
|
FNXC:DashboardShortcuts 2026-07-04-00:00:
|
|
FN-7553 adds four more configurable actions (openFiles, openSettings, openCommandCenter, newTask) on top of the FN-7494/FN-7507 base (quickChat, terminal). Every helper below (resolve/conflict/validate) derives its action list from DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS' keys instead of a hardcoded pair, so future actions only need an entry in the three maps below plus a category assignment.
|
|
*/
|
|
export type DashboardShortcutAction =
|
|
| "quickChat"
|
|
| "terminal"
|
|
| "openFiles"
|
|
| "openSettings"
|
|
| "openCommandCenter"
|
|
| "newTask";
|
|
|
|
export type DashboardKeyboardShortcutMap = Partial<Record<DashboardShortcutAction, string>>;
|
|
|
|
/*
|
|
FNXC:DashboardShortcuts 2026-07-04-00:00:
|
|
New defaults were chosen to avoid colliding with the existing Space/Ctrl+` bindings and with each other: Ctrl+E (open Files), Ctrl+, (open Settings, mirrors the common OS/app "preferences" comma-accelerator), Ctrl+K (open Command Center, the conventional command-palette binding), Ctrl+Shift+N (new Task, avoids the browser-reserved plain Ctrl+N "new window").
|
|
*/
|
|
export const DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS: Required<DashboardKeyboardShortcutMap> = {
|
|
quickChat: "Space",
|
|
terminal: "Ctrl+`",
|
|
openFiles: "Ctrl+E",
|
|
openSettings: "Ctrl+,",
|
|
openCommandCenter: "Ctrl+K",
|
|
newTask: "Ctrl+Shift+N",
|
|
};
|
|
|
|
const ACTION_LABELS: Record<DashboardShortcutAction, string> = {
|
|
quickChat: "Quick Chat",
|
|
terminal: "Terminal",
|
|
openFiles: "Open Files",
|
|
openSettings: "Open Settings",
|
|
openCommandCenter: "Open Command Center",
|
|
newTask: "New Task",
|
|
};
|
|
|
|
export interface DashboardShortcutCategory {
|
|
id: string;
|
|
label: string;
|
|
actions: DashboardShortcutAction[];
|
|
}
|
|
|
|
/*
|
|
FNXC:DashboardShortcuts 2026-07-04-00:00:
|
|
Category grouping backs the dedicated Keyboard Shortcuts settings section (FN-7553) so actions render under headings instead of one flat list. This is UI-only metadata; resolution/conflict/validation logic never depends on category membership.
|
|
*/
|
|
export const SHORTCUT_CATEGORIES: DashboardShortcutCategory[] = [
|
|
{ id: "communication", label: "Communication", actions: ["quickChat"] },
|
|
{ id: "workspace", label: "Workspace", actions: ["terminal", "openFiles"] },
|
|
{ id: "navigation", label: "Navigation", actions: ["openCommandCenter", "openSettings"] },
|
|
{ id: "tasks", label: "Tasks", actions: ["newTask"] },
|
|
];
|
|
|
|
export function getShortcutActionLabel(action: DashboardShortcutAction): string {
|
|
return ACTION_LABELS[action];
|
|
}
|
|
|
|
const MODIFIER_ORDER = ["Ctrl", "Alt", "Shift", "Meta"] as const;
|
|
type ShortcutModifier = (typeof MODIFIER_ORDER)[number];
|
|
|
|
export interface NormalizedShortcut {
|
|
input: string;
|
|
normalized: string;
|
|
display: string;
|
|
key: string;
|
|
modifiers: Record<ShortcutModifier, boolean>;
|
|
disabled: boolean;
|
|
valid: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
export interface ShortcutConflict {
|
|
shortcut: string;
|
|
actions: DashboardShortcutAction[];
|
|
labels: string[];
|
|
}
|
|
|
|
function titleKey(key: string): string {
|
|
if (key === " ") return "Space";
|
|
if (key.length === 1) return key === "`" ? "`" : key.toUpperCase();
|
|
const lower = key.toLowerCase();
|
|
if (lower === "space" || lower === "spacebar") return "Space";
|
|
if (lower === "esc") return "Escape";
|
|
if (lower === "arrowup") return "ArrowUp";
|
|
if (lower === "arrowdown") return "ArrowDown";
|
|
if (lower === "arrowleft") return "ArrowLeft";
|
|
if (lower === "arrowright") return "ArrowRight";
|
|
return key.slice(0, 1).toUpperCase() + key.slice(1);
|
|
}
|
|
|
|
function emptyModifiers(): Record<ShortcutModifier, boolean> {
|
|
return { Ctrl: false, Alt: false, Shift: false, Meta: false };
|
|
}
|
|
|
|
function modifierForToken(token: string): ShortcutModifier | null {
|
|
const lower = token.toLowerCase();
|
|
if (lower === "ctrl" || lower === "control" || lower === "cmdorctrl" || lower === "mod") return "Ctrl";
|
|
if (lower === "alt" || lower === "option") return "Alt";
|
|
if (lower === "shift") return "Shift";
|
|
if (lower === "meta" || lower === "cmd" || lower === "command" || lower === "super") return "Meta";
|
|
return null;
|
|
}
|
|
|
|
/*
|
|
FNXC:DashboardShortcuts 2026-07-04-00:00:
|
|
Shortcut parsing is shared by Settings validation and the App runtime so persisted labels, duplicate detection, and keydown matching cannot diverge. Empty strings are valid disabled bindings; unsupported strings are invalid and must not install runtime listeners.
|
|
*/
|
|
export function normalizeKeyboardShortcut(value: unknown): NormalizedShortcut {
|
|
const input = typeof value === "string" ? value : "";
|
|
const trimmed = input.trim();
|
|
if (!trimmed) {
|
|
return { input, normalized: "", display: "Disabled", key: "", modifiers: emptyModifiers(), disabled: true, valid: true };
|
|
}
|
|
|
|
const parts = trimmed.split("+").map((part) => part.trim()).filter(Boolean);
|
|
if (parts.length === 0) {
|
|
return { input, normalized: "", display: "Disabled", key: "", modifiers: emptyModifiers(), disabled: true, valid: true };
|
|
}
|
|
|
|
const modifiers = emptyModifiers();
|
|
let key = "";
|
|
for (const part of parts) {
|
|
const modifier = modifierForToken(part);
|
|
if (modifier) {
|
|
if (modifiers[modifier]) {
|
|
return { input, normalized: trimmed, display: trimmed, key: "", modifiers, disabled: false, valid: false, error: `Duplicate modifier ${modifier}.` };
|
|
}
|
|
modifiers[modifier] = true;
|
|
continue;
|
|
}
|
|
if (key) {
|
|
return { input, normalized: trimmed, display: trimmed, key: "", modifiers, disabled: false, valid: false, error: "Use one non-modifier key per shortcut." };
|
|
}
|
|
key = titleKey(part);
|
|
}
|
|
|
|
if (!key) {
|
|
return { input, normalized: trimmed, display: trimmed, key: "", modifiers, disabled: false, valid: false, error: "Add a key after the modifier." };
|
|
}
|
|
|
|
if (key.length !== 1 && !/^(Space|Escape|Enter|Tab|Backspace|Delete|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|F\d{1,2})$/.test(key)) {
|
|
return { input, normalized: trimmed, display: trimmed, key, modifiers, disabled: false, valid: false, error: "Use a printable key, Space, Escape, arrows, Tab, Enter, Delete, Backspace, or F1-F12." };
|
|
}
|
|
|
|
const normalizedParts: string[] = MODIFIER_ORDER.filter((modifier) => modifiers[modifier]);
|
|
normalizedParts.push(key);
|
|
const normalized = normalizedParts.join("+");
|
|
return { input, normalized, display: normalized, key, modifiers, disabled: false, valid: true };
|
|
}
|
|
|
|
export function resolveDashboardKeyboardShortcuts(settings: DashboardKeyboardShortcutMap | null | undefined): Required<DashboardKeyboardShortcutMap> {
|
|
const resolved = {} as Required<DashboardKeyboardShortcutMap>;
|
|
(Object.keys(DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS) as DashboardShortcutAction[]).forEach((action) => {
|
|
resolved[action] = settings?.[action] ?? DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS[action];
|
|
});
|
|
return resolved;
|
|
}
|
|
|
|
export function findShortcutConflicts(shortcuts: DashboardKeyboardShortcutMap): ShortcutConflict[] {
|
|
const seen = new Map<string, DashboardShortcutAction[]>();
|
|
(Object.keys(DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS) as DashboardShortcutAction[]).forEach((action) => {
|
|
const parsed = normalizeKeyboardShortcut(shortcuts[action] ?? "");
|
|
if (!parsed.valid || parsed.disabled) return;
|
|
const actions = seen.get(parsed.normalized) ?? [];
|
|
actions.push(action);
|
|
seen.set(parsed.normalized, actions);
|
|
});
|
|
|
|
return Array.from(seen.entries())
|
|
.filter(([, actions]) => actions.length > 1)
|
|
.map(([shortcut, actions]) => ({ shortcut, actions, labels: actions.map((action) => ACTION_LABELS[action]) }));
|
|
}
|
|
|
|
/*
|
|
FNXC:DashboardShortcuts 2026-07-04-00:00:
|
|
Space and interface-opening shortcuts must ignore both text-entry and interactive controls so they do not steal typing or button/menu activation. Escape uses the narrower text-entry guard so document-level popup dismissal still works from ordinary controls while editors and terminal input keep ownership.
|
|
*/
|
|
const TEXT_ENTRY_SHORTCUT_TARGET_SELECTOR = "input, textarea, select, [contenteditable=''], [contenteditable='true'], [role='textbox'], [data-shortcuts-ignore='true']";
|
|
const INTERACTIVE_SHORTCUT_TARGET_SELECTOR = `${TEXT_ENTRY_SHORTCUT_TARGET_SELECTOR}, button, a[href], summary, [role='button'], [role='link'], [role='checkbox'], [role='radio'], [role='switch'], [role='tab'], [role='menuitem']`;
|
|
|
|
function closestShortcutTarget(target: EventTarget | null, selector: string): Element | null {
|
|
if (typeof Element === "undefined" || !(target instanceof Element)) return null;
|
|
return target.closest(selector);
|
|
}
|
|
|
|
export function isTextEntryShortcutTarget(target: EventTarget | null): boolean {
|
|
const editable = closestShortcutTarget(target, TEXT_ENTRY_SHORTCUT_TARGET_SELECTOR);
|
|
if (!editable) return false;
|
|
if (editable instanceof HTMLInputElement) {
|
|
const type = editable.type.toLowerCase();
|
|
return !["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"].includes(type);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function isEditableShortcutTarget(target: EventTarget | null): boolean {
|
|
return Boolean(closestShortcutTarget(target, INTERACTIVE_SHORTCUT_TARGET_SELECTOR));
|
|
}
|
|
|
|
export function shortcutMatchesEvent(shortcut: string | undefined, event: KeyboardEvent): boolean {
|
|
const parsed = normalizeKeyboardShortcut(shortcut ?? "");
|
|
if (!parsed.valid || parsed.disabled) return false;
|
|
const eventKey = event.key === " " ? "Space" : titleKey(event.key);
|
|
return eventKey === parsed.key
|
|
&& event.ctrlKey === parsed.modifiers.Ctrl
|
|
&& event.altKey === parsed.modifiers.Alt
|
|
&& event.shiftKey === parsed.modifiers.Shift
|
|
&& event.metaKey === parsed.modifiers.Meta;
|
|
}
|
|
|
|
export function describeShortcutValidation(shortcuts: DashboardKeyboardShortcutMap): string | null {
|
|
const invalid = (Object.keys(DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS) as DashboardShortcutAction[])
|
|
.map((action) => ({ action, parsed: normalizeKeyboardShortcut(shortcuts[action] ?? "") }))
|
|
.find(({ parsed }) => !parsed.valid);
|
|
if (invalid) return `${ACTION_LABELS[invalid.action]} shortcut is invalid: ${invalid.parsed.error ?? "Use a supported key combination."}`;
|
|
const conflict = findShortcutConflicts(shortcuts)[0];
|
|
if (conflict) return `${conflict.labels.join(" and ")} both use ${conflict.shortcut}. Choose unique shortcuts or disable one.`;
|
|
return null;
|
|
}
|