feat(shortcuts): allow shortcuts to be unassigned (#2510)

Allow users to clear and restore shortcut bindings, persist the explicit unassigned state, and keep shortcut displays and actions aligned with the binding that fires. Includes focused unit coverage and desktop renderer Playwright coverage.
This commit is contained in:
Christoph Leiter
2026-08-09 01:31:40 +02:00
committed by GitHub
parent c28836b4e5
commit 90aa8bbefc
22 changed files with 1247 additions and 115 deletions
@@ -8,7 +8,11 @@ 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";
import {
buildEffectiveBindings,
buildKeyboardShortcutHelpSections,
} from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
const SNAP_POINTS: string[] = ["70%", "92%"];
@@ -42,9 +46,13 @@ export function KeyboardShortcutsDialog() {
const shortcutOs = getShortcutOs();
const isMac = shortcutOs === "mac";
const isDesktopApp = getIsElectronRuntime();
const { overrides } = useKeyboardShortcutOverrides();
// Effective bindings, so a shortcut the user unassigned lists no keys here
// instead of advertising a default that no longer fires.
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
const sections = useMemo(
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }),
[isDesktopApp, isMac],
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }, bindings),
[bindings, isDesktopApp, isMac],
);
const visibleSections = useMemo(() => {
const normalizedQuery = query.trim().toLocaleLowerCase();
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { normalizeDisplayChord } from "./normalize-display-chord";
describe("normalizeDisplayChord", () => {
it("returns null when neither keys nor chord is given", () => {
expect(normalizeDisplayChord()).toBeNull();
});
it("returns null for an empty keys array", () => {
// The trap: [] wraps to [[]], whose first element is truthy.
expect(normalizeDisplayChord(undefined, [])).toBeNull();
});
it("returns null for an empty chord", () => {
expect(normalizeDisplayChord([])).toBeNull();
});
it("returns null for a chord holding an empty combo", () => {
expect(normalizeDisplayChord([[]])).toBeNull();
expect(normalizeDisplayChord([["mod", "K"], []])).toBeNull();
});
it("wraps keys into a single-combo chord", () => {
expect(normalizeDisplayChord(undefined, ["mod", "K"])).toEqual([["mod", "K"]]);
});
it("passes a multi-step chord through unchanged", () => {
expect(normalizeDisplayChord([["mod", "K"], ["S"]])).toEqual([["mod", "K"], ["S"]]);
});
it("prefers chord over keys when both are given", () => {
expect(normalizeDisplayChord([["alt", "W"]], ["mod", "K"])).toEqual([["alt", "W"]]);
});
});
@@ -0,0 +1,28 @@
import type { ShortcutKey } from "@/utils/format-shortcut";
/**
* The combos a `Shortcut` should render, or `null` when it should render
* nothing at all.
*
* `keys={[]}` is the trap this exists to close: it wraps to `[[]]`, whose first
* element is an empty array — truthy — so a first-element check waves it
* through and an empty badge pill renders. A shortcut with no keys, whether the
* user unassigned it or it ships without a default combo, has to produce no
* element at all.
*/
export function normalizeDisplayChord(
chord?: ShortcutKey[][],
keys?: ShortcutKey[],
): ShortcutKey[][] | null {
const combos = chord ?? (keys ? [keys] : []);
if (combos.length === 0) {
return null;
}
// A chord with an empty step cannot be drawn honestly: rendering it would
// show a blank pill, and dropping the step would advertise a shorter
// sequence than the one that actually fires.
if (combos.some((combo) => combo.length === 0)) {
return null;
}
return combos;
}
+7 -4
View File
@@ -2,6 +2,7 @@ import React, { useMemo, type ReactElement } from "react";
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { normalizeDisplayChord } from "@/components/ui/normalize-display-chord";
import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform";
@@ -17,9 +18,8 @@ export function Shortcut({
textStyle?: StyleProp<TextStyle>;
}): ReactElement | null {
const shortcutsAvailable = useKeyboardShortcutsAvailable();
const displayChord = chord ?? (keys ? [keys] : []);
const displayChord = normalizeDisplayChord(chord, keys);
const shortcutOs = getShortcutOs();
const singleCombo = displayChord[0];
const badgeStyle = useMemo(() => [styles.badge, style], [style]);
const textCombinedStyle = useMemo(() => [styles.text, textStyle], [textStyle]);
@@ -29,8 +29,11 @@ export function Shortcut({
return null;
}
if (!singleCombo) {
return <View style={style} />;
const singleCombo = displayChord?.[0];
// Render nothing, literally — an empty <View> would still consume the
// parent's `gap` and leave a phantom slot where the badge used to be.
if (!displayChord || !singleCombo) {
return null;
}
if (displayChord.length === 1) {