Add keyboard shortcut rebinding with string-based format and sectioned settings UI
- Parse/format shortcut strings ("Cmd+Shift+O") bidirectionally via shortcut-string.ts
- Convert all 60+ bindings from structured KeyCombo objects to declarative strings
- Add keyboard shortcut overrides storage (AsyncStorage + React Query)
- Wire overrides into the keyboard shortcut resolution pipeline
- Add sectioned settings layout with sidebar on desktop, single scroll on mobile
- Add keyboard shortcuts settings section with rebind/reset UI and key capture
- Create useShortcutKeys hook so tooltip shortcut displays update on rebind
- Replace hardcoded shortcut displays in agent-input-area, left-sidebar, command center
This commit is contained in:
@@ -13,6 +13,11 @@ import {
|
||||
} from "@/utils/command-center-focus-restore";
|
||||
import { buildHostSettingsRoute, parseServerIdFromPathname } from "@/utils/host-routes";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { comboStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
|
||||
@@ -48,7 +53,7 @@ type CommandCenterActionDefinition = {
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: "plus" | "settings";
|
||||
shortcutKeys?: ShortcutKey[];
|
||||
actionId?: string;
|
||||
keywords: string[];
|
||||
routeKind: "settings" | "none";
|
||||
};
|
||||
@@ -58,7 +63,7 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
||||
id: "new-agent",
|
||||
title: "Open project",
|
||||
icon: "plus",
|
||||
shortcutKeys: ["mod", "shift", "O"],
|
||||
actionId: "new-agent",
|
||||
keywords: ["open", "project", "folder", "workspace", "repo"],
|
||||
routeKind: "none",
|
||||
},
|
||||
@@ -99,9 +104,25 @@ export type CommandCenterItem =
|
||||
agent: AggregatedAgent;
|
||||
};
|
||||
|
||||
function resolveActionShortcutKeys(
|
||||
actionId: string | undefined,
|
||||
overrides: Record<string, string>,
|
||||
): ShortcutKey[] | undefined {
|
||||
if (!actionId) return undefined;
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const isDesktop = getIsDesktop();
|
||||
const platform = { isMac, isDesktop };
|
||||
const bindingId = getBindingIdForAction(actionId, platform);
|
||||
if (!bindingId) return undefined;
|
||||
const override = overrides[bindingId];
|
||||
if (override) return comboStringToShortcutKeys(override);
|
||||
return getDefaultKeysForAction(actionId, platform) ?? undefined;
|
||||
}
|
||||
|
||||
export function useCommandCenter() {
|
||||
const pathname = usePathname();
|
||||
const daemons = useHosts();
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
@@ -158,9 +179,9 @@ export function useCommandCenter() {
|
||||
title: action.title,
|
||||
icon: action.icon,
|
||||
route: action.routeKind === "settings" ? settingsRoute : undefined,
|
||||
shortcutKeys: action.shortcutKeys,
|
||||
shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides),
|
||||
}));
|
||||
}, [open, query, settingsRoute]);
|
||||
}, [open, query, settingsRoute, overrides]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!open) {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
const STORAGE_KEY = "@paseo:keyboard-shortcut-overrides";
|
||||
const QUERY_KEY = ["keyboard-shortcut-overrides"];
|
||||
|
||||
const EMPTY_OVERRIDES: Record<string, string> = {};
|
||||
|
||||
export interface UseKeyboardShortcutOverridesReturn {
|
||||
overrides: Record<string, string>;
|
||||
isLoading: boolean;
|
||||
setOverride: (bindingId: string, comboString: string) => Promise<void>;
|
||||
removeOverride: (bindingId: string) => Promise<void>;
|
||||
resetAll: () => Promise<void>;
|
||||
hasOverrides: boolean;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcutOverrides(): UseKeyboardShortcutOverridesReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isPending } = useQuery({
|
||||
queryKey: QUERY_KEY,
|
||||
queryFn: loadOverridesFromStorage,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
|
||||
const setOverride = useCallback(
|
||||
async (bindingId: string, comboString: string) => {
|
||||
const prev =
|
||||
queryClient.getQueryData<Record<string, string>>(QUERY_KEY) ?? EMPTY_OVERRIDES;
|
||||
const next = { ...prev, [bindingId]: comboString };
|
||||
queryClient.setQueryData<Record<string, string>>(QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const removeOverride = useCallback(
|
||||
async (bindingId: string) => {
|
||||
const prev =
|
||||
queryClient.getQueryData<Record<string, string>>(QUERY_KEY) ?? EMPTY_OVERRIDES;
|
||||
const { [bindingId]: _, ...next } = prev;
|
||||
queryClient.setQueryData<Record<string, string>>(QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const resetAll = useCallback(async () => {
|
||||
queryClient.setQueryData<Record<string, string>>(QUERY_KEY, EMPTY_OVERRIDES);
|
||||
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||
}, [queryClient]);
|
||||
|
||||
const overrides = data ?? EMPTY_OVERRIDES;
|
||||
|
||||
return {
|
||||
overrides,
|
||||
isLoading: isPending,
|
||||
setOverride,
|
||||
removeOverride,
|
||||
resetAll,
|
||||
hasOverrides: Object.keys(overrides).length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadOverridesFromStorage(): Promise<Record<string, string>> {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as Record<string, string>;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[KeyboardShortcutOverrides] Failed to load overrides:", err);
|
||||
}
|
||||
return EMPTY_OVERRIDES;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { usePathname } from "expo-router";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
@@ -17,10 +17,11 @@ import {
|
||||
} from "@/keyboard/actions";
|
||||
import { canToggleFileExplorerShortcut } from "@/keyboard/keyboard-shortcut-routing";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { resolveKeyboardShortcut } from "@/keyboard/keyboard-shortcuts";
|
||||
import { resolveKeyboardShortcut, buildEffectiveBindings } from "@/keyboard/keyboard-shortcuts";
|
||||
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
|
||||
export function useKeyboardShortcuts({
|
||||
enabled,
|
||||
@@ -38,6 +39,8 @@ export function useKeyboardShortcuts({
|
||||
const pathname = usePathname();
|
||||
const hosts = useHosts();
|
||||
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
|
||||
const activeServerIdFromPath = parseServerIdFromPathname(pathname);
|
||||
const activeServerId =
|
||||
hosts.find((host) => host.serverId === activeServerIdFromPath)?.serverId ??
|
||||
@@ -285,6 +288,7 @@ export function useKeyboardShortcuts({
|
||||
toggleFileExplorer,
|
||||
}),
|
||||
},
|
||||
bindings,
|
||||
});
|
||||
if (!match) {
|
||||
return;
|
||||
@@ -332,6 +336,7 @@ export function useKeyboardShortcuts({
|
||||
document.removeEventListener("visibilitychange", handleBlurOrHide);
|
||||
};
|
||||
}, [
|
||||
bindings,
|
||||
enabled,
|
||||
isMobile,
|
||||
openProjectPickerAction,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useMemo } from "react";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { comboStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
|
||||
export function useShortcutKeys(actionId: string): ShortcutKey[] | null {
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const isDesktop = getIsDesktop();
|
||||
|
||||
return useMemo(() => {
|
||||
const platform = { isMac, isDesktop };
|
||||
const bindingId = getBindingIdForAction(actionId, platform);
|
||||
if (!bindingId) return null;
|
||||
|
||||
const override = overrides[bindingId];
|
||||
if (override) {
|
||||
return comboStringToShortcutKeys(override);
|
||||
}
|
||||
|
||||
return getDefaultKeysForAction(actionId, platform);
|
||||
}, [actionId, overrides, isMac, isDesktop]);
|
||||
}
|
||||
Reference in New Issue
Block a user