From 551b5ce4fa90412e39dbd23bcd2f2dafe95ad15e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:09:09 +0100 Subject: [PATCH] fix: name the account sign out leaves in the command palette, add star, hide dead key hints --- .../__tests__/command-palette.test.tsx | 35 +++++++++++++++++++ .../__tests__/mail-palette-commands.test.ts | 28 +++++++++++++++ .../command-palette/command-palette.tsx | 21 ++++++++--- components/mail/mail-app.tsx | 6 +++- lib/single-key-shortcuts.ts | 9 +++++ 5 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 components/command-palette/__tests__/mail-palette-commands.test.ts diff --git a/components/command-palette/__tests__/command-palette.test.tsx b/components/command-palette/__tests__/command-palette.test.tsx index 2bd4fae1e..927e2b174 100644 --- a/components/command-palette/__tests__/command-palette.test.tsx +++ b/components/command-palette/__tests__/command-palette.test.tsx @@ -119,6 +119,41 @@ describe('command palette', () => { expect(hint('search')).toBe(''); }); + it('shows no single-key hints while single-key shortcuts are off, since those keys do nothing then', async () => { + const { useSettingsStore } = await import('@/stores/settings-store'); + const hint = (label: string) => { + const row = screen.getAllByRole('option').find((o) => o.querySelector('.flex-1')?.textContent === label)!; + return [...row.querySelectorAll('kbd')].map((k) => k.textContent).join(' '); + }; + useSettingsStore.setState({ singleKeyShortcuts: false }); + try { + render(); + pressCtrlK(); + expect(hint('compose')).toBe(''); + expect(hint('shortcuts')).toBe(''); + } finally { + useSettingsStore.setState({ singleKeyShortcuts: true }); + } + }); + + it('names the account Sign out leaves, as the account menu does', async () => { + const { useAccountStore } = await import('@/stores/account-store'); + const before = useAccountStore.getState().accounts; + useAccountStore.setState({ accounts: [ + { id: 'a1', email: 'one@example.org', username: 'one@example.org' }, + { id: 'a2', email: 'two@example.org', username: 'two@example.org' }, + ] } as never); + useAuthStore.setState({ activeAccountId: 'a2' } as never); + try { + render(); + pressCtrlK(); + expect(screen.getByText('sign_out_of(two@example.org)')).toBeInTheDocument(); + expect(screen.queryByText('sign_out')).toBeNull(); + } finally { + useAccountStore.setState({ accounts: before } as never); + } + }); + it('shows plugin entries with the plugin name and runs them with Enter', async () => { act(() => setPluginActions('jira', 'Jira', 'command-palette', [{ id: 'new-issue', label: 'Create issue' }])); render(); diff --git a/components/command-palette/__tests__/mail-palette-commands.test.ts b/components/command-palette/__tests__/mail-palette-commands.test.ts new file mode 100644 index 000000000..247954e94 --- /dev/null +++ b/components/command-palette/__tests__/mail-palette-commands.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +// Mail registers its palette commands in mail-app.tsx, which is too big to render +// here. Each message action with a key in the shortcuts help is in the palette too, +// under the key the help names. +const source = readFileSync(path.join(process.cwd(), 'components/mail/mail-app.tsx'), 'utf8'); +const block = source.slice(source.indexOf("useRegisterCommands(t('sidebar.mail')")); + +describe('Mail commands in the palette', () => { + it.each([ + ['reply', 'r'], + ['reply-all', 'a'], + ['forward', 'f'], + ['star', 's'], + ['archive', 'e'], + ['delete', '#'], + ['mark-unread', 'u'], + ['mark-read', 'Shift + I'], + ['spam', '!'], + ])('has %s, shown with %s', (id, key) => { + const at = block.indexOf(`id: '${id}'`); + expect(at).toBeGreaterThan(-1); + const entry = block.slice(at, block.indexOf('}', at)); + expect(entry).toContain(`shortcut: '${key}'`); + }); +}); diff --git a/components/command-palette/command-palette.tsx b/components/command-palette/command-palette.tsx index 058d4a448..8169769bd 100644 --- a/components/command-palette/command-palette.tsx +++ b/components/command-palette/command-palette.tsx @@ -27,6 +27,7 @@ import { useGlobalSearchStore } from "@/stores/global-search-store"; import { useCalendarStore } from "@/stores/calendar-store"; import { usePolicyStore } from "@/stores/policy-store"; import { useThemeStore } from "@/stores/theme-store"; +import { useSettingsStore } from "@/stores/settings-store"; import { usePluginActions } from "@/components/plugins/plugin-actions"; import { usePluginPages, pluginPageHref } from "@/hooks/use-plugin-pages"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; @@ -38,7 +39,7 @@ import { commandMatches, useRegisteredCommandGroups } from "@/lib/command-regist import { shortcutAlternatives } from "@/lib/shortcut-keys"; import { toUnicodeEmail } from "@/lib/idn"; import { isEditableEventTarget, isKeyboardLayerOpen } from "@/lib/keyboard"; -import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts"; +import { isSingleKeyHint, isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts"; import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useMediaQuery } from "@/hooks/use-media-query"; import { IconButton } from "@/components/ui/icon-button"; @@ -162,6 +163,9 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS const activeAccountId = useAuthStore((s) => s.activeAccountId); // A touch screen has no keyboard to use the shortcuts with (R8). const touch = useMediaQuery("(pointer: coarse)"); + // With single-key shortcuts off, "r" or "#" does nothing: no hint for it. + const singleKeys = useSettingsStore((s) => s.singleKeyShortcuts !== false); + const hintKeys = (shortcut: string) => shortcutAlternatives(shortcut).filter((key) => singleKeys || !isSingleKeyHint(key)); const inputRef = useRef(null); const dialogRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true, initialFocus: () => inputRef.current }); @@ -255,7 +259,16 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS run: () => { void useAuthStore.getState().switchAccount(entry.id); }, }); } - list.push({ id: "sign-out", group: account, icon: LogOut, label: tSidebar("sign_out"), run: () => { void useAuthStore.getState().logout(); } }); + // It signs out of the open account only, as the account menu's entry says. + const current = accounts.find((entry) => entry.id === activeAccountId); + const currentName = current ? toUnicodeEmail(current.email || current.username) : ""; + list.push({ + id: "sign-out", + group: account, + icon: LogOut, + label: currentName ? tSidebar("sign_out_of", { account: currentName }) : tSidebar("sign_out"), + run: () => { void useAuthStore.getState().logout(); }, + }); for (const action of pluginActions) { list.push({ id: `plugin:${action.pluginId}:${action.id}`, @@ -394,10 +407,10 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS {command.source && ( {command.source} )} - {command.shortcut && ( + {command.shortcut && hintKeys(command.shortcut).length > 0 && ( // Key hints only where there is a keyboard (R8). diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 11d4246c0..e22795c00 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -136,7 +136,7 @@ import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { openMailImport } from "@/stores/mail-transfer-store"; import { deliveryAddresses, resolveComposeAccountEmail } from "@/lib/reply-identity"; import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; -import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder } from "@/components/icons"; +import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder, Star } from "@/components/icons"; import { useRegisterCommands } from "@/lib/command-registry"; import { MoveToDialog } from "@/components/email/move-to-dialog"; import { ResizeHandle } from "@/components/layout/resize-handle"; @@ -4813,6 +4813,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { when: () => !!paletteEmail && !paletteEmail.keywords?.$seen, run: () => { if (client && paletteEmail) void markReadWithUndo(paletteEmail, true).catch(toastUpdateFailure); }, }, + { + id: 'star', label: paletteEmail?.keywords?.$flagged ? t('context_menu.unstar') : t('context_menu.star'), icon: Star, shortcut: 's', + when: hasPaletteEmail, run: () => void handleToggleStar(), + }, { id: 'spam', label: conversationInJunk ? t('context_menu.not_spam') : t('context_menu.mark_as_spam'), icon: conversationInJunk ? ShieldCheck : ShieldAlert, shortcut: '!', diff --git a/lib/single-key-shortcuts.ts b/lib/single-key-shortcuts.ts index e3e8477d8..26cba959c 100644 --- a/lib/single-key-shortcuts.ts +++ b/lib/single-key-shortcuts.ts @@ -10,3 +10,12 @@ import { useSettingsStore } from "@/stores/settings-store"; export function isSingleKeyShortcutOff(event: Pick): boolean { return isSingleCharacterKey(event) && useSettingsStore.getState().singleKeyShortcuts === false; } + +/** + * A key hint as the help and menus write it ("r", "Shift + I", "#") that's a single-key + * shortcut, so it does nothing while those are off. Arrows aren't characters. + */ +export function isSingleKeyHint(combo: string): boolean { + const key = combo.replace(/^(shift\s*\+\s*)+/i, "").trim(); + return [...key].length === 1 && !/[←→↑↓]/.test(key); +}