Make keyboard shortcuts searchable (#2160)

* feat(app): make keyboard shortcuts searchable

* test(app): cover shortcut search empty state

* fix(app): index shortcut modifier aliases
This commit is contained in:
Mohamed Boudra
2026-07-18 11:54:49 +02:00
committed by GitHub
parent b4a5b6a3ff
commit 745e8afe45
10 changed files with 113 additions and 4 deletions

View File

@@ -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);

View File

@@ -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<string[][]>(
(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<SheetHeader>(() => ({ title: t("settings.shortcuts.dialogTitle") }), [t]);
const header = useMemo<SheetHeader>(
() => ({
title: t("settings.shortcuts.dialogTitle"),
search: {
onChange: setQuery,
resetKey: Number(open),
placeholder: t("settings.shortcuts.searchPlaceholder"),
autoFocus: true,
},
}),
[open, t],
);
return (
<AdaptiveModalSheet
@@ -35,7 +101,7 @@ export function KeyboardShortcutsDialog() {
snapPoints={SNAP_POINTS}
>
<View testID="keyboard-shortcuts-dialog-content" style={styles.content}>
{sections.map((section) => (
{visibleSections.map((section) => (
<View key={section.title} style={styles.section}>
<Text style={styles.sectionTitle}>{t(section.titleKey)}</Text>
<View style={styles.rows}>
@@ -53,6 +119,9 @@ export function KeyboardShortcutsDialog() {
</View>
</View>
))}
{visibleSections.length === 0 ? (
<Text style={styles.empty}>{t("common.empty.noResults")}</Text>
) : null}
</View>
</AdaptiveModalSheet>
);
@@ -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,
},
}));

View File

@@ -1713,6 +1713,7 @@ export const ar: TranslationResources = {
},
shortcuts: {
dialogTitle: "الاختصارات",
searchPlaceholder: "البحث في الاختصارات",
unavailableOnMobile: "اختصارات لوحة المفاتيح متاحة فقط على سطح المكتب",
capturePrompt: "اضغط على الاختصار...",
actions: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -1731,6 +1731,7 @@ export const ja: TranslationResources = {
},
shortcuts: {
dialogTitle: "ショートカット",
searchPlaceholder: "ショートカットを検索",
unavailableOnMobile: "キーボードショートカットはデスクトップでのみ利用できます",
capturePrompt: "ショートカットを押してください...",
actions: {

View File

@@ -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: {

View File

@@ -1752,6 +1752,7 @@ export const ru: TranslationResources = {
},
shortcuts: {
dialogTitle: "Ярлыки",
searchPlaceholder: "Поиск сочетаний клавиш",
unavailableOnMobile: "Сочетания клавиш доступны только на рабочем столе.",
capturePrompt: "Нажмите ярлык...",
actions: {

View File

@@ -1694,6 +1694,7 @@ export const zhCN: TranslationResources = {
},
shortcuts: {
dialogTitle: "快捷键",
searchPlaceholder: "搜索快捷键",
unavailableOnMobile: "键盘快捷键仅在桌面端可用",
capturePrompt: "按下快捷键...",
actions: {