From 745e8afe45f04374e26e85e69aa47bc52a11b390 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 18 Jul 2026 11:54:49 +0200 Subject: [PATCH] Make keyboard shortcuts searchable (#2160) * feat(app): make keyboard shortcuts searchable * test(app): cover shortcut search empty state * fix(app): index shortcut modifier aliases --- packages/app/e2e/sidebar-help.spec.ts | 26 ++++++ .../components/keyboard-shortcuts-dialog.tsx | 83 ++++++++++++++++++- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + 10 files changed, 113 insertions(+), 4 deletions(-) diff --git a/packages/app/e2e/sidebar-help.spec.ts b/packages/app/e2e/sidebar-help.spec.ts index abf6919f1..129251516 100644 --- a/packages/app/e2e/sidebar-help.spec.ts +++ b/packages/app/e2e/sidebar-help.spec.ts @@ -81,6 +81,32 @@ test("opens support and release destinations", async ({ page }) => { await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION); }); +test("searches keyboard shortcuts from the sidebar help menu", async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, "platform", { get: () => "MacIntel" }); + }); + await gotoAppShell(page); + await openHelpMenu(page); + await page.getByTestId("sidebar-help-shortcuts").click(); + + const dialog = page.getByTestId("keyboard-shortcuts-dialog"); + const search = page.getByPlaceholder("Search shortcuts"); + + await search.fill("command+n"); + await expect(dialog.getByText("New workspace", { exact: true })).toBeVisible(); + + await search.fill("interrupt"); + + await expect(dialog.getByText("Interrupt agent", { exact: true })).toBeVisible(); + await expect(dialog.getByText("New workspace", { exact: true })).toHaveCount(0); + + await search.fill("no matching shortcut"); + await expect(dialog.getByText("No results found", { exact: true })).toBeVisible(); + + await search.fill(""); + await expect(dialog.getByText("New workspace", { exact: true })).toBeVisible(); +}); + test("keeps diagnostics available from Settings after globalizing the sheet", async ({ page }) => { await gotoAppShell(page); await openSettings(page); diff --git a/packages/app/src/components/keyboard-shortcuts-dialog.tsx b/packages/app/src/components/keyboard-shortcuts-dialog.tsx index ea90e6ed1..770f1193d 100644 --- a/packages/app/src/components/keyboard-shortcuts-dialog.tsx +++ b/packages/app/src/components/keyboard-shortcuts-dialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; @@ -6,25 +6,91 @@ import { getIsElectronRuntime } from "@/constants/layout"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Shortcut } from "@/components/ui/shortcut"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; +import { formatShortcut } from "@/utils/format-shortcut"; import { getShortcutOs } from "@/utils/shortcut-platform"; import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts"; const SNAP_POINTS: string[] = ["70%", "92%"]; +function shortcutSearchAliases(keys: string[], shortcutOs: "mac" | "non-mac"): string { + const aliases = keys.map((key) => { + if (shortcutOs === "mac") { + if (key === "mod" || key === "meta") return ["cmd", "command"]; + if (key === "alt") return ["alt", "option"]; + } else { + if (key === "mod" || key === "ctrl") return ["ctrl", "control"]; + if (key === "meta") return ["win", "windows"]; + } + return [key]; + }); + const combinations = aliases.reduce( + (prefixes, choices) => + prefixes.flatMap((prefix) => choices.map((choice) => [...prefix, choice])), + [[]], + ); + return combinations + .flatMap((combination) => [combination.join(" "), combination.join("+")]) + .join(" "); +} + export function KeyboardShortcutsDialog() { const { t } = useTranslation(); const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen); const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen); + const [query, setQuery] = useState(""); - const isMac = getShortcutOs() === "mac"; + const shortcutOs = getShortcutOs(); + const isMac = shortcutOs === "mac"; const isDesktopApp = getIsElectronRuntime(); const sections = useMemo( () => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }), [isDesktopApp, isMac], ); + const visibleSections = useMemo(() => { + const normalizedQuery = query.trim().toLocaleLowerCase(); + if (!normalizedQuery) return sections; + + return sections.flatMap((section) => { + const sectionTitle = t(section.titleKey); + if (sectionTitle.toLocaleLowerCase().includes(normalizedQuery)) { + return [section]; + } + + const rows = section.rows.filter((row) => { + const searchText = [ + t(row.labelKey), + row.noteKey ? t(row.noteKey) : row.note, + row.keys.join(" "), + formatShortcut(row.keys, shortcutOs), + shortcutSearchAliases(row.keys, shortcutOs), + ] + .filter(Boolean) + .join(" ") + .toLocaleLowerCase(); + return searchText.includes(normalizedQuery); + }); + + return rows.length > 0 ? [{ ...section, rows }] : []; + }); + }, [query, sections, shortcutOs, t]); + + useEffect(() => { + if (!open) setQuery(""); + }, [open]); const handleClose = useCallback(() => setOpen(false), [setOpen]); - const header = useMemo(() => ({ title: t("settings.shortcuts.dialogTitle") }), [t]); + const header = useMemo( + () => ({ + title: t("settings.shortcuts.dialogTitle"), + search: { + onChange: setQuery, + resetKey: Number(open), + placeholder: t("settings.shortcuts.searchPlaceholder"), + autoFocus: true, + }, + }), + [open, t], + ); return ( - {sections.map((section) => ( + {visibleSections.map((section) => ( {t(section.titleKey)} @@ -53,6 +119,9 @@ export function KeyboardShortcutsDialog() { ))} + {visibleSections.length === 0 ? ( + {t("common.empty.noResults")} + ) : null} ); @@ -102,4 +171,10 @@ const styles = StyleSheet.create((theme) => ({ rowShortcut: { alignSelf: "flex-start", }, + empty: { + paddingVertical: theme.spacing[6], + textAlign: "center", + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, })); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 38379dd5a..3062b5e9c 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1713,6 +1713,7 @@ export const ar: TranslationResources = { }, shortcuts: { dialogTitle: "الاختصارات", + searchPlaceholder: "البحث في الاختصارات", unavailableOnMobile: "اختصارات لوحة المفاتيح متاحة فقط على سطح المكتب", capturePrompt: "اضغط على الاختصار...", actions: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 641176146..d04201091 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1724,6 +1724,7 @@ export const en = { }, shortcuts: { dialogTitle: "Shortcuts", + searchPlaceholder: "Search shortcuts", unavailableOnMobile: "Keyboard shortcuts are only available on desktop", capturePrompt: "Press shortcut...", actions: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 3b1ebb85c..58221329b 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1761,6 +1761,7 @@ export const es: TranslationResources = { }, shortcuts: { dialogTitle: "Atajos", + searchPlaceholder: "Buscar atajos", unavailableOnMobile: "Los atajos de teclado solo están disponibles en el escritorio", capturePrompt: "Presione el acceso directo...", actions: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 5525f1be5..9083dc98c 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1762,6 +1762,7 @@ export const fr: TranslationResources = { }, shortcuts: { dialogTitle: "Raccourcis", + searchPlaceholder: "Rechercher des raccourcis", unavailableOnMobile: "Les raccourcis clavier ne sont disponibles que sur le bureau", capturePrompt: "Appuyez sur le raccourci...", actions: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 29000af6a..ff5c83e9b 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1731,6 +1731,7 @@ export const ja: TranslationResources = { }, shortcuts: { dialogTitle: "ショートカット", + searchPlaceholder: "ショートカットを検索", unavailableOnMobile: "キーボードショートカットはデスクトップでのみ利用できます", capturePrompt: "ショートカットを押してください...", actions: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index f3db413ac..3d5f1f20b 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1745,6 +1745,7 @@ export const ptBR: TranslationResources = { }, shortcuts: { dialogTitle: "Atalhos", + searchPlaceholder: "Pesquisar atalhos", unavailableOnMobile: "Atalhos de teclado estão disponíveis apenas no desktop", capturePrompt: "Pressione o atalho...", actions: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index e5cda739e..57f147682 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1752,6 +1752,7 @@ export const ru: TranslationResources = { }, shortcuts: { dialogTitle: "Ярлыки", + searchPlaceholder: "Поиск сочетаний клавиш", unavailableOnMobile: "Сочетания клавиш доступны только на рабочем столе.", capturePrompt: "Нажмите ярлык...", actions: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 65b8c970c..2feb4684a 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1694,6 +1694,7 @@ export const zhCN: TranslationResources = { }, shortcuts: { dialogTitle: "快捷键", + searchPlaceholder: "搜索快捷键", unavailableOnMobile: "键盘快捷键仅在桌面端可用", capturePrompt: "按下快捷键...", actions: {