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:
@@ -81,6 +81,32 @@ test("opens support and release destinations", async ({ page }) => {
|
|||||||
await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION);
|
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 }) => {
|
test("keeps diagnostics available from Settings after globalizing the sheet", async ({ page }) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
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 { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
|
import { formatShortcut } from "@/utils/format-shortcut";
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts";
|
import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts";
|
||||||
|
|
||||||
const SNAP_POINTS: string[] = ["70%", "92%"];
|
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() {
|
export function KeyboardShortcutsDialog() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen);
|
const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen);
|
||||||
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
|
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 isDesktopApp = getIsElectronRuntime();
|
||||||
const sections = useMemo(
|
const sections = useMemo(
|
||||||
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }),
|
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }),
|
||||||
[isDesktopApp, isMac],
|
[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 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 (
|
return (
|
||||||
<AdaptiveModalSheet
|
<AdaptiveModalSheet
|
||||||
@@ -35,7 +101,7 @@ export function KeyboardShortcutsDialog() {
|
|||||||
snapPoints={SNAP_POINTS}
|
snapPoints={SNAP_POINTS}
|
||||||
>
|
>
|
||||||
<View testID="keyboard-shortcuts-dialog-content" style={styles.content}>
|
<View testID="keyboard-shortcuts-dialog-content" style={styles.content}>
|
||||||
{sections.map((section) => (
|
{visibleSections.map((section) => (
|
||||||
<View key={section.title} style={styles.section}>
|
<View key={section.title} style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>{t(section.titleKey)}</Text>
|
<Text style={styles.sectionTitle}>{t(section.titleKey)}</Text>
|
||||||
<View style={styles.rows}>
|
<View style={styles.rows}>
|
||||||
@@ -53,6 +119,9 @@ export function KeyboardShortcutsDialog() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
{visibleSections.length === 0 ? (
|
||||||
|
<Text style={styles.empty}>{t("common.empty.noResults")}</Text>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</AdaptiveModalSheet>
|
</AdaptiveModalSheet>
|
||||||
);
|
);
|
||||||
@@ -102,4 +171,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
rowShortcut: {
|
rowShortcut: {
|
||||||
alignSelf: "flex-start",
|
alignSelf: "flex-start",
|
||||||
},
|
},
|
||||||
|
empty: {
|
||||||
|
paddingVertical: theme.spacing[6],
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1713,6 +1713,7 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
dialogTitle: "الاختصارات",
|
dialogTitle: "الاختصارات",
|
||||||
|
searchPlaceholder: "البحث في الاختصارات",
|
||||||
unavailableOnMobile: "اختصارات لوحة المفاتيح متاحة فقط على سطح المكتب",
|
unavailableOnMobile: "اختصارات لوحة المفاتيح متاحة فقط على سطح المكتب",
|
||||||
capturePrompt: "اضغط على الاختصار...",
|
capturePrompt: "اضغط على الاختصار...",
|
||||||
actions: {
|
actions: {
|
||||||
|
|||||||
@@ -1724,6 +1724,7 @@ export const en = {
|
|||||||
},
|
},
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
dialogTitle: "Shortcuts",
|
dialogTitle: "Shortcuts",
|
||||||
|
searchPlaceholder: "Search shortcuts",
|
||||||
unavailableOnMobile: "Keyboard shortcuts are only available on desktop",
|
unavailableOnMobile: "Keyboard shortcuts are only available on desktop",
|
||||||
capturePrompt: "Press shortcut...",
|
capturePrompt: "Press shortcut...",
|
||||||
actions: {
|
actions: {
|
||||||
|
|||||||
@@ -1761,6 +1761,7 @@ export const es: TranslationResources = {
|
|||||||
},
|
},
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
dialogTitle: "Atajos",
|
dialogTitle: "Atajos",
|
||||||
|
searchPlaceholder: "Buscar atajos",
|
||||||
unavailableOnMobile: "Los atajos de teclado solo están disponibles en el escritorio",
|
unavailableOnMobile: "Los atajos de teclado solo están disponibles en el escritorio",
|
||||||
capturePrompt: "Presione el acceso directo...",
|
capturePrompt: "Presione el acceso directo...",
|
||||||
actions: {
|
actions: {
|
||||||
|
|||||||
@@ -1762,6 +1762,7 @@ export const fr: TranslationResources = {
|
|||||||
},
|
},
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
dialogTitle: "Raccourcis",
|
dialogTitle: "Raccourcis",
|
||||||
|
searchPlaceholder: "Rechercher des raccourcis",
|
||||||
unavailableOnMobile: "Les raccourcis clavier ne sont disponibles que sur le bureau",
|
unavailableOnMobile: "Les raccourcis clavier ne sont disponibles que sur le bureau",
|
||||||
capturePrompt: "Appuyez sur le raccourci...",
|
capturePrompt: "Appuyez sur le raccourci...",
|
||||||
actions: {
|
actions: {
|
||||||
|
|||||||
@@ -1731,6 +1731,7 @@ export const ja: TranslationResources = {
|
|||||||
},
|
},
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
dialogTitle: "ショートカット",
|
dialogTitle: "ショートカット",
|
||||||
|
searchPlaceholder: "ショートカットを検索",
|
||||||
unavailableOnMobile: "キーボードショートカットはデスクトップでのみ利用できます",
|
unavailableOnMobile: "キーボードショートカットはデスクトップでのみ利用できます",
|
||||||
capturePrompt: "ショートカットを押してください...",
|
capturePrompt: "ショートカットを押してください...",
|
||||||
actions: {
|
actions: {
|
||||||
|
|||||||
@@ -1745,6 +1745,7 @@ export const ptBR: TranslationResources = {
|
|||||||
},
|
},
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
dialogTitle: "Atalhos",
|
dialogTitle: "Atalhos",
|
||||||
|
searchPlaceholder: "Pesquisar atalhos",
|
||||||
unavailableOnMobile: "Atalhos de teclado estão disponíveis apenas no desktop",
|
unavailableOnMobile: "Atalhos de teclado estão disponíveis apenas no desktop",
|
||||||
capturePrompt: "Pressione o atalho...",
|
capturePrompt: "Pressione o atalho...",
|
||||||
actions: {
|
actions: {
|
||||||
|
|||||||
@@ -1752,6 +1752,7 @@ export const ru: TranslationResources = {
|
|||||||
},
|
},
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
dialogTitle: "Ярлыки",
|
dialogTitle: "Ярлыки",
|
||||||
|
searchPlaceholder: "Поиск сочетаний клавиш",
|
||||||
unavailableOnMobile: "Сочетания клавиш доступны только на рабочем столе.",
|
unavailableOnMobile: "Сочетания клавиш доступны только на рабочем столе.",
|
||||||
capturePrompt: "Нажмите ярлык...",
|
capturePrompt: "Нажмите ярлык...",
|
||||||
actions: {
|
actions: {
|
||||||
|
|||||||
@@ -1694,6 +1694,7 @@ export const zhCN: TranslationResources = {
|
|||||||
},
|
},
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
dialogTitle: "快捷键",
|
dialogTitle: "快捷键",
|
||||||
|
searchPlaceholder: "搜索快捷键",
|
||||||
unavailableOnMobile: "键盘快捷键仅在桌面端可用",
|
unavailableOnMobile: "键盘快捷键仅在桌面端可用",
|
||||||
capturePrompt: "按下快捷键...",
|
capturePrompt: "按下快捷键...",
|
||||||
actions: {
|
actions: {
|
||||||
|
|||||||
Reference in New Issue
Block a user