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>
173 lines
6.1 KiB
TypeScript
173 lines
6.1 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "@fusion/core";
|
|
import {
|
|
ALL_PROJECT_RESET_KEYS,
|
|
EXCLUDED_RESET_SECTIONS,
|
|
getResetIneligibleReason,
|
|
getSectionKeyEntry,
|
|
isRegistryKeyValidForScope,
|
|
isResetEligibleSection,
|
|
} from "../section-keys";
|
|
|
|
const GLOBAL_KEY_SET = new Set<string>(GLOBAL_SETTINGS_KEYS as readonly string[]);
|
|
const PROJECT_KEY_SET = new Set<string>(PROJECT_SETTINGS_KEYS as readonly string[]);
|
|
|
|
/** Every key-owning section id we expect the registry to resolve, with its declared scope. */
|
|
const EXPECTED_KEY_OWNING_SECTIONS: Record<string, "global" | "project"> = {
|
|
// global sections (reused from GLOBAL_SECTION_KEYS in save-split.ts)
|
|
appearance: "global",
|
|
notifications: "global",
|
|
experimental: "global",
|
|
"global-general": "global",
|
|
"keyboard-shortcuts": "global",
|
|
"global-models": "global",
|
|
"node-sync": "global",
|
|
"research-global": "global",
|
|
remote: "global",
|
|
// project sections (new for FN-7506)
|
|
general: "project",
|
|
commands: "project",
|
|
worktrees: "project",
|
|
scheduling: "project",
|
|
"scheduled-evals": "project",
|
|
"node-routing": "project",
|
|
merge: "project",
|
|
"agent-permissions": "project",
|
|
backups: "project",
|
|
"research-project": "project",
|
|
"project-models": "project",
|
|
};
|
|
|
|
const EXPECTED_EXCLUDED_SECTIONS = [
|
|
"secrets",
|
|
"global-mcp",
|
|
"mcp",
|
|
"plugins",
|
|
"memory",
|
|
"authentication",
|
|
"prompts",
|
|
"cli-agents",
|
|
"hermes-runtime",
|
|
"openclaw-runtime",
|
|
"paperclip-runtime",
|
|
];
|
|
|
|
describe("settings section-keys registry", () => {
|
|
it("resolves every expected key-owning section with the correct scope", () => {
|
|
for (const [sectionId, scope] of Object.entries(EXPECTED_KEY_OWNING_SECTIONS)) {
|
|
const entry = getSectionKeyEntry(sectionId);
|
|
expect(entry, `expected ${sectionId} to be reset-eligible`).not.toBeNull();
|
|
expect(entry!.scope).toBe(scope);
|
|
expect(entry!.keys.length).toBeGreaterThan(0);
|
|
expect(isResetEligibleSection(sectionId)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("every registry key is a real member of the canonical scope key set matching its declared scope", () => {
|
|
for (const [sectionId, scope] of Object.entries(EXPECTED_KEY_OWNING_SECTIONS)) {
|
|
const entry = getSectionKeyEntry(sectionId)!;
|
|
for (const key of entry.keys) {
|
|
const validForDeclaredScope = isRegistryKeyValidForScope(key, scope);
|
|
expect(
|
|
validForDeclaredScope,
|
|
`section "${sectionId}" claims key "${key}" at scope "${scope}", but it is not a member of the matching ${scope === "global" ? "GLOBAL_SETTINGS_KEYS" : "PROJECT_SETTINGS_KEYS"} set`,
|
|
).toBe(true);
|
|
|
|
if (scope === "global") {
|
|
expect(GLOBAL_KEY_SET.has(key)).toBe(true);
|
|
} else {
|
|
expect(PROJECT_KEY_SET.has(key)).toBe(true);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it("no key is claimed by two sections at the same scope", () => {
|
|
const seenAtScope: Record<"global" | "project", Map<string, string>> = {
|
|
global: new Map(),
|
|
project: new Map(),
|
|
};
|
|
|
|
for (const [sectionId, scope] of Object.entries(EXPECTED_KEY_OWNING_SECTIONS)) {
|
|
const entry = getSectionKeyEntry(sectionId)!;
|
|
for (const key of entry.keys) {
|
|
const owner = seenAtScope[scope].get(key);
|
|
expect(
|
|
owner,
|
|
`key "${key}" at scope "${scope}" is claimed by both "${owner}" and "${sectionId}"`,
|
|
).toBeUndefined();
|
|
seenAtScope[scope].set(key, sectionId);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("excludes non-key sections explicitly, with a documented reason, and marks them reset-ineligible", () => {
|
|
for (const sectionId of EXPECTED_EXCLUDED_SECTIONS) {
|
|
expect(getSectionKeyEntry(sectionId)).toBeNull();
|
|
expect(isResetEligibleSection(sectionId)).toBe(false);
|
|
expect(getResetIneligibleReason(sectionId)).toBeTruthy();
|
|
expect(EXCLUDED_RESET_SECTIONS[sectionId]).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
it("treats unknown/group-header section ids as reset-ineligible without a reason", () => {
|
|
expect(getSectionKeyEntry("__project_header")).toBeNull();
|
|
expect(isResetEligibleSection("__project_header")).toBe(false);
|
|
expect(getResetIneligibleReason("__project_header")).toBeUndefined();
|
|
});
|
|
|
|
it("a representative project section (merge) maps to its expected owned keys", () => {
|
|
const entry = getSectionKeyEntry("merge")!;
|
|
expect(entry.scope).toBe("project");
|
|
expect(new Set(entry.keys)).toEqual(
|
|
new Set([
|
|
"autoMerge",
|
|
"autoResolveConflicts",
|
|
"commitAuthorEmail",
|
|
"commitAuthorEnabled",
|
|
"commitAuthorName",
|
|
"directMergeCommitStrategy",
|
|
"githubAuthMode",
|
|
"githubAuthToken",
|
|
"gitlabAuthToken",
|
|
"gitlabAuthTokenType",
|
|
"includeTaskIdInCommit",
|
|
"integrationBranch",
|
|
"maxAutoMergeRetries",
|
|
"mergeAdvanceAutoSync",
|
|
"mergeConflictStrategy",
|
|
"mergeIntegrationWorktree",
|
|
"mergeStrategy",
|
|
"mergeStrategyOverlapBehavior",
|
|
"merger",
|
|
"planApprovalMode",
|
|
"postMergeAuditMode",
|
|
"pushAfterMerge",
|
|
"pushRemote",
|
|
"smartConflictResolution",
|
|
"testMode",
|
|
]),
|
|
);
|
|
// gitlabEnabled's enable+URL fields are owned by "general" instead, not duplicated here.
|
|
expect(entry.keys).not.toContain("gitlabEnabled");
|
|
});
|
|
|
|
it("a representative global section (appearance) maps to its expected owned keys", () => {
|
|
const entry = getSectionKeyEntry("appearance")!;
|
|
expect(entry.scope).toBe("global");
|
|
expect(new Set(entry.keys)).toEqual(
|
|
new Set(["themeMode", "colorTheme", "dashboardFontScalePct", "shadcnCustomColors"]),
|
|
);
|
|
});
|
|
|
|
it("ALL_PROJECT_RESET_KEYS contains only project keys and never global-only keys", () => {
|
|
expect(ALL_PROJECT_RESET_KEYS.length).toBeGreaterThan(0);
|
|
for (const key of ALL_PROJECT_RESET_KEYS) {
|
|
expect(PROJECT_KEY_SET.has(key)).toBe(true);
|
|
}
|
|
// Sanity: a couple of known global-only keys must not sneak into the project set.
|
|
expect(ALL_PROJECT_RESET_KEYS).not.toContain("themeMode");
|
|
expect(ALL_PROJECT_RESET_KEYS).not.toContain("ntfyEnabled");
|
|
});
|
|
});
|