-
diff --git a/locales/ar/common.json b/locales/ar/common.json
index e7c22faf8..c7b929ad1 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -4348,6 +4348,7 @@
"loading": "جارٍ تحميل الصفحة",
"not_available_title": "هذه الصفحة غير متاحة",
"not_available_description": "الإضافة التي أضافتها متوقفة أو لم تعد مثبتة.",
- "back_to_mail": "العودة إلى البريد"
+ "back_to_mail": "العودة إلى البريد",
+ "settings_group": "الإضافات"
}
}
diff --git a/locales/ca/common.json b/locales/ca/common.json
index f67d00186..4ccf50925 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -4348,6 +4348,7 @@
"loading": "S'està carregant la pàgina",
"not_available_title": "Aquesta pàgina no està disponible",
"not_available_description": "El connector que l'ha afegida està desactivat o ja no està instal·lat.",
- "back_to_mail": "Torna al correu"
+ "back_to_mail": "Torna al correu",
+ "settings_group": "Connectors"
}
}
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 109b6973f..db973baee 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -4348,6 +4348,7 @@
"loading": "Načítání stránky",
"not_available_title": "Tato stránka není k dispozici",
"not_available_description": "Plugin, který ji přidal, je vypnutý nebo už není nainstalovaný.",
- "back_to_mail": "Zpět na poštu"
+ "back_to_mail": "Zpět na poštu",
+ "settings_group": "Pluginy"
}
}
diff --git a/locales/da/common.json b/locales/da/common.json
index d683bffdb..a10f232d6 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -4348,6 +4348,7 @@
"loading": "Indlæser side",
"not_available_title": "Denne side er ikke tilgængelig",
"not_available_description": "Det plugin, der tilføjede den, er slået fra eller ikke længere installeret.",
- "back_to_mail": "Tilbage til mail"
+ "back_to_mail": "Tilbage til mail",
+ "settings_group": "Plugins"
}
}
diff --git a/locales/de/common.json b/locales/de/common.json
index 8996d7a55..5e92726e5 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -4348,6 +4348,7 @@
"loading": "Seite wird geladen",
"not_available_title": "Diese Seite ist nicht verfügbar",
"not_available_description": "Das Plugin, das sie hinzugefügt hat, ist ausgeschaltet oder nicht mehr installiert.",
- "back_to_mail": "Zurück zu E-Mail"
+ "back_to_mail": "Zurück zu E-Mail",
+ "settings_group": "Plugins"
}
}
diff --git a/locales/en/common.json b/locales/en/common.json
index 3580708a5..19efd2e2d 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -4348,6 +4348,7 @@
"loading": "Loading page",
"not_available_title": "This page is not available",
"not_available_description": "The plugin that added it is turned off or no longer installed.",
- "back_to_mail": "Back to Mail"
+ "back_to_mail": "Back to Mail",
+ "settings_group": "Plugins"
}
}
diff --git a/locales/es/common.json b/locales/es/common.json
index 0bfaca8c0..1a8dca501 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -4348,6 +4348,7 @@
"loading": "Cargando la página",
"not_available_title": "Esta página no está disponible",
"not_available_description": "El complemento que la añadió está desactivado o ya no está instalado.",
- "back_to_mail": "Volver al correo"
+ "back_to_mail": "Volver al correo",
+ "settings_group": "Complementos"
}
}
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 1a1f376df..528937cf9 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -4348,6 +4348,7 @@
"loading": "در حال بارگذاری صفحه",
"not_available_title": "این صفحه در دسترس نیست",
"not_available_description": "افزونهای که آن را اضافه کرده خاموش است یا دیگر نصب نیست.",
- "back_to_mail": "بازگشت به ایمیل"
+ "back_to_mail": "بازگشت به ایمیل",
+ "settings_group": "افزونهها"
}
}
diff --git a/locales/fr/common.json b/locales/fr/common.json
index d70b4ddb3..2e0ae2324 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -4348,6 +4348,7 @@
"loading": "Chargement de la page",
"not_available_title": "Cette page n'est pas disponible",
"not_available_description": "Le plugin qui l'a ajoutée est désactivé ou n'est plus installé.",
- "back_to_mail": "Retour à la messagerie"
+ "back_to_mail": "Retour à la messagerie",
+ "settings_group": "Plugins"
}
}
diff --git a/locales/he/common.json b/locales/he/common.json
index 46579d546..f4a4245dc 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -4349,6 +4349,7 @@
"loading": "טוען את הדף",
"not_available_title": "הדף הזה אינו זמין",
"not_available_description": "התוסף שהוסיף אותו כבוי או שאינו מותקן עוד.",
- "back_to_mail": "חזרה לדואר"
+ "back_to_mail": "חזרה לדואר",
+ "settings_group": "תוספים"
}
}
diff --git a/locales/hu/common.json b/locales/hu/common.json
index d4adacfbc..ade921b05 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -4348,6 +4348,7 @@
"loading": "Oldal betöltése",
"not_available_title": "Ez az oldal nem érhető el",
"not_available_description": "A bővítmény, amely hozzáadta, ki van kapcsolva, vagy már nincs telepítve.",
- "back_to_mail": "Vissza a levelekhez"
+ "back_to_mail": "Vissza a levelekhez",
+ "settings_group": "Bővítmények"
}
}
diff --git a/locales/it/common.json b/locales/it/common.json
index e1aefbb38..09b71a121 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -4348,6 +4348,7 @@
"loading": "Caricamento della pagina",
"not_available_title": "Questa pagina non è disponibile",
"not_available_description": "Il plugin che l'ha aggiunta è disattivato o non è più installato.",
- "back_to_mail": "Torna alla posta"
+ "back_to_mail": "Torna alla posta",
+ "settings_group": "Plugin"
}
}
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 13e558a56..18ec80be7 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -4348,6 +4348,7 @@
"loading": "ページを読み込んでいます",
"not_available_title": "このページは利用できません",
"not_available_description": "このページを追加したプラグインがオフになっているか、インストールされていません。",
- "back_to_mail": "メールに戻る"
+ "back_to_mail": "メールに戻る",
+ "settings_group": "プラグイン"
}
}
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 32796dbc5..574296434 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -4348,6 +4348,7 @@
"loading": "페이지를 불러오는 중",
"not_available_title": "이 페이지를 사용할 수 없습니다",
"not_available_description": "이 페이지를 추가한 플러그인이 꺼져 있거나 더 이상 설치되어 있지 않습니다.",
- "back_to_mail": "메일로 돌아가기"
+ "back_to_mail": "메일로 돌아가기",
+ "settings_group": "플러그인"
}
}
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 1671f1f80..c1aaa5b39 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -4348,6 +4348,7 @@
"loading": "Ielādē lapu",
"not_available_title": "Šī lapa nav pieejama",
"not_available_description": "Spraudnis, kas to pievienoja, ir izslēgts vai vairs nav instalēts.",
- "back_to_mail": "Atpakaļ uz pastu"
+ "back_to_mail": "Atpakaļ uz pastu",
+ "settings_group": "Spraudņi"
}
}
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 3f6bc6aa5..8cb5de007 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -4348,6 +4348,7 @@
"loading": "Хуудсыг ачаалж байна",
"not_available_title": "Энэ хуудас боломжгүй байна",
"not_available_description": "Үүнийг нэмсэн залгаас унтарсан эсвэл суулгаагүй болсон байна.",
- "back_to_mail": "Мэйл рүү буцах"
+ "back_to_mail": "Мэйл рүү буцах",
+ "settings_group": "Залгаасууд"
}
}
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 618eb7ed2..265a29f23 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -4348,6 +4348,7 @@
"loading": "Laster inn siden",
"not_available_title": "Denne siden er ikke tilgjengelig",
"not_available_description": "Tillegget som la den til, er slått av eller ikke lenger installert.",
- "back_to_mail": "Tilbake til e-post"
+ "back_to_mail": "Tilbake til e-post",
+ "settings_group": "Tillegg"
}
}
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 2737163ff..cc09602ff 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -4348,6 +4348,7 @@
"loading": "Pagina laden",
"not_available_title": "Deze pagina is niet beschikbaar",
"not_available_description": "De plug-in die hem heeft toegevoegd, staat uit of is niet meer geïnstalleerd.",
- "back_to_mail": "Terug naar e-mail"
+ "back_to_mail": "Terug naar e-mail",
+ "settings_group": "Plug-ins"
}
}
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 77c7da7af..33ab46d13 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -4348,6 +4348,7 @@
"loading": "Wczytywanie strony",
"not_available_title": "Ta strona jest niedostępna",
"not_available_description": "Wtyczka, która ją dodała, jest wyłączona lub nie jest już zainstalowana.",
- "back_to_mail": "Wróć do poczty"
+ "back_to_mail": "Wróć do poczty",
+ "settings_group": "Wtyczki"
}
}
diff --git a/locales/pt/common.json b/locales/pt/common.json
index d79aa1f20..590212fe3 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -4348,6 +4348,7 @@
"loading": "Carregando a página",
"not_available_title": "Esta página não está disponível",
"not_available_description": "O plugin que a adicionou está desativado ou não está mais instalado.",
- "back_to_mail": "Voltar ao e-mail"
+ "back_to_mail": "Voltar ao e-mail",
+ "settings_group": "Plugins"
}
}
diff --git a/locales/ro/common.json b/locales/ro/common.json
index e32ccf6d7..a196ba6da 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -4348,6 +4348,7 @@
"loading": "Se încarcă pagina",
"not_available_title": "Această pagină nu este disponibilă",
"not_available_description": "Pluginul care a adăugat-o este dezactivat sau nu mai este instalat.",
- "back_to_mail": "Înapoi la e-mail"
+ "back_to_mail": "Înapoi la e-mail",
+ "settings_group": "Pluginuri"
}
}
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 3a34d9a6a..ddaf252cf 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -4348,6 +4348,7 @@
"loading": "Загрузка страницы",
"not_available_title": "Эта страница недоступна",
"not_available_description": "Плагин, который её добавил, выключен или больше не установлен.",
- "back_to_mail": "Вернуться к почте"
+ "back_to_mail": "Вернуться к почте",
+ "settings_group": "Плагины"
}
}
diff --git a/locales/sk/common.json b/locales/sk/common.json
index a8b5a529a..10e6a696d 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -4348,6 +4348,7 @@
"loading": "Načítava sa stránka",
"not_available_title": "Táto stránka nie je k dispozícii",
"not_available_description": "Doplnok, ktorý ju pridal, je vypnutý alebo už nie je nainštalovaný.",
- "back_to_mail": "Späť na poštu"
+ "back_to_mail": "Späť na poštu",
+ "settings_group": "Doplnky"
}
}
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 2f941652c..07b5cfdf9 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -4348,6 +4348,7 @@
"loading": "Sayfa yükleniyor",
"not_available_title": "Bu sayfa kullanılamıyor",
"not_available_description": "Bu sayfayı ekleyen eklenti kapalı veya artık yüklü değil.",
- "back_to_mail": "Postaya dön"
+ "back_to_mail": "Postaya dön",
+ "settings_group": "Eklentiler"
}
}
diff --git a/locales/uk/common.json b/locales/uk/common.json
index e8d0efe2b..9a4a3ac34 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -4348,6 +4348,7 @@
"loading": "Завантаження сторінки",
"not_available_title": "Ця сторінка недоступна",
"not_available_description": "Плагін, який її додав, вимкнено або більше не встановлено.",
- "back_to_mail": "Повернутися до пошти"
+ "back_to_mail": "Повернутися до пошти",
+ "settings_group": "Плагіни"
}
}
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 1b5282302..5921f9d64 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -4348,6 +4348,7 @@
"loading": "正在載入頁面",
"not_available_title": "此頁面無法使用",
"not_available_description": "新增此頁面的外掛程式已關閉或已不再安裝。",
- "back_to_mail": "返回郵件"
+ "back_to_mail": "返回郵件",
+ "settings_group": "外掛程式"
}
}
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 31155ee2d..56b1d091f 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -4348,6 +4348,7 @@
"loading": "正在加载页面",
"not_available_title": "此页面不可用",
"not_available_description": "添加此页面的插件已关闭或已不再安装。",
- "back_to_mail": "返回邮件"
+ "back_to_mail": "返回邮件",
+ "settings_group": "插件"
}
}
From 86e593a75299146708e9144388f34e51479a2ab3 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 14 Oct 2026 05:10:10 +0200
Subject: [PATCH 5/7] feat: add a command palette on Ctrl+K where plugins can
add their own commands
---
app/(main)/[locale]/layout.tsx | 2 +
.../keyboard-shortcuts-palette.test.tsx | 24 ++
.../__tests__/command-palette.test.tsx | 174 +++++++++++
.../command-palette/command-palette.tsx | 288 ++++++++++++++++++
components/keyboard-shortcuts-modal.tsx | 6 +-
.../__tests__/top-bar-plugin-actions.test.tsx | 11 +-
components/layout/mobile-header.tsx | 21 +-
.../mail-app-top-bar-actions.test.tsx | 17 ++
components/mail/mail-app.tsx | 10 +
hooks/use-keyboard-shortcuts.ts | 5 +-
lib/__tests__/app-commands.test.tsx | 38 +++
lib/app-commands.ts | 66 ++++
locales/ar/common.json | 15 +
locales/ca/common.json | 15 +
locales/cs/common.json | 15 +
locales/da/common.json | 15 +
locales/de/common.json | 15 +
locales/en/common.json | 15 +
locales/es/common.json | 15 +
locales/fa/common.json | 15 +
locales/fr/common.json | 15 +
locales/he/common.json | 15 +
locales/hu/common.json | 15 +
locales/it/common.json | 15 +
locales/ja/common.json | 15 +
locales/ko/common.json | 15 +
locales/lv/common.json | 15 +
locales/mn/common.json | 15 +
locales/nb/common.json | 15 +
locales/nl/common.json | 15 +
locales/pl/common.json | 15 +
locales/pt/common.json | 15 +
locales/ro/common.json | 15 +
locales/ru/common.json | 15 +
locales/sk/common.json | 15 +
locales/tr/common.json | 15 +
locales/uk/common.json | 15 +
locales/zh-TW/common.json | 15 +
locales/zh/common.json | 15 +
39 files changed, 1054 insertions(+), 13 deletions(-)
create mode 100644 components/__tests__/keyboard-shortcuts-palette.test.tsx
create mode 100644 components/command-palette/__tests__/command-palette.test.tsx
create mode 100644 components/command-palette/command-palette.tsx
create mode 100644 lib/__tests__/app-commands.test.tsx
create mode 100644 lib/app-commands.ts
diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx
index de115e8a8..b4e444621 100644
--- a/app/(main)/[locale]/layout.tsx
+++ b/app/(main)/[locale]/layout.tsx
@@ -9,6 +9,7 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler";
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
+import { CommandPaletteHost } from "@/components/command-palette/command-palette";
import { PluginConnectDialog } from "@/components/plugins/plugin-connect-dialog";
import { QuickRuleHost } from "@/components/filters/quick-rule-host";
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
@@ -58,6 +59,7 @@ export default async function LocaleLayout({
{children}
+
diff --git a/components/__tests__/keyboard-shortcuts-palette.test.tsx b/components/__tests__/keyboard-shortcuts-palette.test.tsx
new file mode 100644
index 000000000..87567955a
--- /dev/null
+++ b/components/__tests__/keyboard-shortcuts-palette.test.tsx
@@ -0,0 +1,24 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+const pro = vi.hoisted(() => ({ active: false }));
+vi.mock('@/components/pro/pro-interface-redirect', () => ({ useProInterfaceActive: () => pro.active }));
+vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ startTour: vi.fn() }) }));
+
+const { KeyboardShortcutsModal } = await import('../keyboard-shortcuts-modal');
+
+describe('keyboard shortcuts help', () => {
+ it('lists Ctrl+K as the command palette outside the Pro shell', () => {
+ pro.active = false;
+ render(
{}} />);
+ expect(screen.getByText('command_palette.open')).toBeInTheDocument();
+ expect(screen.queryByText('shortcuts.global.global_search')).toBeNull();
+ });
+
+ it('lists Ctrl+K as global search in the Pro shell', () => {
+ pro.active = true;
+ render( {}} />);
+ expect(screen.getByText('shortcuts.global.global_search')).toBeInTheDocument();
+ expect(screen.queryByText('command_palette.open')).toBeNull();
+ });
+});
diff --git a/components/command-palette/__tests__/command-palette.test.tsx b/components/command-palette/__tests__/command-palette.test.tsx
new file mode 100644
index 000000000..b7869b0bb
--- /dev/null
+++ b/components/command-palette/__tests__/command-palette.test.tsx
@@ -0,0 +1,174 @@
+import React from 'react';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
+
+const nav = vi.hoisted(() => ({ pathname: '/', push: vi.fn() }));
+
+vi.mock('next-intl', () => ({
+ useTranslations: () => (key: string, params?: Record) =>
+ params ? `${key}(${Object.values(params).join(',')})` : key,
+ useLocale: () => 'en',
+}));
+vi.mock('@/i18n/navigation', () => ({
+ useRouter: () => ({ push: nav.push, back: vi.fn(), replace: vi.fn() }),
+ usePathname: () => nav.pathname,
+ Link: ({ children }: { children: React.ReactNode }) => children,
+}));
+vi.mock('@/components/keyboard-shortcuts-modal', () => ({
+ KeyboardShortcutsModal: ({ isOpen }: { isOpen: boolean }) => (isOpen ? shortcuts help
: null),
+}));
+
+const { CommandPaletteHost } = await import('../command-palette');
+const { useAuthStore } = await import('@/stores/auth-store');
+const { useThemeStore } = await import('@/stores/theme-store');
+const appCommands = await import('@/lib/app-commands');
+
+const dispatch = vi.fn().mockResolvedValue(undefined);
+const pressCtrlK = (target: Element = document.body) =>
+ act(() => { fireEvent.keyDown(target, { key: 'k', code: 'KeyK', ctrlKey: true }); });
+
+beforeEach(() => {
+ nav.pathname = '/';
+ nav.push.mockClear();
+ resetActions();
+ dispatch.mockClear();
+ setActionDispatcher(dispatch);
+ useAuthStore.setState({ isAuthenticated: true, client: null } as never);
+});
+afterEach(() => {
+ appCommands.parkAppCommand(null as never);
+});
+
+describe('command palette', () => {
+ it('opens on Ctrl+K with the search field focused, and closes on Escape', () => {
+ render( );
+ expect(screen.queryByRole('dialog')).toBeNull();
+
+ pressCtrlK();
+ expect(screen.getByRole('dialog', { name: 'title' })).toBeInTheDocument();
+ const input = screen.getByRole('combobox');
+ expect(input).toHaveFocus();
+ expect(input).toHaveAttribute('aria-controls', screen.getByRole('listbox').id);
+
+ act(() => { fireEvent.keyDown(input, { key: 'Escape' }); });
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+
+ it('opens from a menu entry (phones)', () => {
+ render( );
+ act(() => appCommands.openCommandPalette());
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('leaves Ctrl+K alone while typing in a field', () => {
+ render(<> >);
+ pressCtrlK(screen.getByLabelText('other field'));
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+
+ it('leaves Ctrl+K to global search in the Pro shell, and does nothing signed out', () => {
+ nav.pathname = '/pro';
+ const { unmount } = render( );
+ pressCtrlK();
+ expect(screen.queryByRole('dialog')).toBeNull();
+ unmount();
+
+ nav.pathname = '/';
+ useAuthStore.setState({ isAuthenticated: false } as never);
+ render( );
+ pressCtrlK();
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+
+ it('lists the app commands and no plugin group without plugin entries', () => {
+ render( );
+ pressCtrlK();
+
+ const labels = screen.getAllByRole('option').map((o) => o.textContent);
+ expect(labels).toContain('go_to(mail)');
+ expect(labels).toContain('go_to(settings)');
+ expect(labels).toContain('compose');
+ expect(labels).toContain('shortcuts');
+ expect(screen.queryByText('group_plugins')).toBeNull();
+ });
+
+ 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( );
+ pressCtrlK();
+
+ expect(screen.getByText('group_plugins')).toBeInTheDocument();
+ const option = screen.getByRole('option', { name: /Create issue/ });
+ expect(option).toHaveTextContent('Jira');
+
+ const input = screen.getByRole('combobox');
+ act(() => { fireEvent.change(input, { target: { value: 'issue' } }); });
+ expect(screen.getAllByRole('option')).toHaveLength(1);
+ expect(screen.getByRole('option')).toHaveAttribute('aria-selected', 'true');
+ expect(input).toHaveAttribute('aria-activedescendant', screen.getByRole('option').id);
+
+ await act(async () => { fireEvent.keyDown(input, { key: 'Enter' }); });
+ expect(dispatch).toHaveBeenCalledWith('jira', 'command-palette', 'new-issue', {});
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+
+ it('finds plugin entries by plugin name too', () => {
+ act(() => setPluginActions('jira', 'Jira', 'command-palette', [{ id: 'new-issue', label: 'Create issue' }]));
+ render( );
+ pressCtrlK();
+ act(() => { fireEvent.change(screen.getByRole('combobox'), { target: { value: 'jir' } }); });
+ expect(screen.getAllByRole('option').map((o) => o.getAttribute('data-command-id'))).toEqual(['plugin:jira:new-issue']);
+ });
+
+ it('moves with the arrow keys and wraps around', () => {
+ render( );
+ pressCtrlK();
+ const input = screen.getByRole('combobox');
+ const options = () => screen.getAllByRole('option');
+
+ expect(options()[0]).toHaveAttribute('aria-selected', 'true');
+ act(() => { fireEvent.keyDown(input, { key: 'ArrowDown' }); });
+ expect(options()[1]).toHaveAttribute('aria-selected', 'true');
+ act(() => { fireEvent.keyDown(input, { key: 'ArrowUp' }); });
+ act(() => { fireEvent.keyDown(input, { key: 'ArrowUp' }); });
+ expect(options()[options().length - 1]).toHaveAttribute('aria-selected', 'true');
+ });
+
+ it('navigates with a go-to command', () => {
+ render( );
+ pressCtrlK();
+ act(() => { fireEvent.click(screen.getByRole('option', { name: 'go_to(settings)' })); });
+ expect(nav.push).toHaveBeenCalledWith('/settings');
+ });
+
+ it('hands compose to the mail page, or opens mail first and parks it', () => {
+ const taken = vi.fn((e: Event) => e.preventDefault());
+ window.addEventListener('bulwark:app-command', taken);
+ render( );
+ pressCtrlK();
+ act(() => { fireEvent.click(screen.getByRole('option', { name: 'compose' })); });
+ expect(taken).toHaveBeenCalled();
+ expect(nav.push).not.toHaveBeenCalled();
+ window.removeEventListener('bulwark:app-command', taken);
+
+ pressCtrlK();
+ act(() => { fireEvent.click(screen.getByRole('option', { name: 'compose' })); });
+ expect(nav.push).toHaveBeenCalledWith('/');
+ expect(appCommands.peekParkedAppCommand()).toBe('compose');
+ });
+
+ it('switches the theme and shows the keyboard shortcuts', () => {
+ useThemeStore.setState({ resolvedTheme: 'light' } as never);
+ const setTheme = vi.fn();
+ useThemeStore.setState({ setTheme } as never);
+ render( );
+ pressCtrlK();
+ act(() => { fireEvent.click(screen.getByRole('option', { name: 'theme_dark' })); });
+ expect(setTheme).toHaveBeenCalledWith('dark');
+
+ pressCtrlK();
+ act(() => { fireEvent.click(screen.getByRole('option', { name: 'shortcuts' })); });
+ expect(screen.getByText('shortcuts help')).toBeInTheDocument();
+ });
+});
diff --git a/components/command-palette/command-palette.tsx b/components/command-palette/command-palette.tsx
new file mode 100644
index 000000000..d3c4b8d4d
--- /dev/null
+++ b/components/command-palette/command-palette.tsx
@@ -0,0 +1,288 @@
+"use client";
+
+import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
+import { useTranslations } from "next-intl";
+import {
+ BookUser,
+ Calendar,
+ HardDrive,
+ Keyboard,
+ Mail,
+ Moon,
+ PenLine,
+ Puzzle,
+ Search,
+ Settings,
+ Sun,
+ iconForName,
+} from "@/components/icons";
+import type { AppIcon } from "@/components/icons";
+import { usePathname, useRouter } from "@/i18n/navigation";
+import { useAuthStore } from "@/stores/auth-store";
+import { useCalendarStore } from "@/stores/calendar-store";
+import { usePolicyStore } from "@/stores/policy-store";
+import { useThemeStore } from "@/stores/theme-store";
+import { usePluginActions } from "@/components/plugins/plugin-actions";
+import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
+import { runAction } from "@/lib/plugin-sandbox/actions";
+import { COMMAND_PALETTE_OPEN_EVENT, parkAppCommand, requestAppCommand, type AppCommand } from "@/lib/app-commands";
+import { isEditableEventTarget } from "@/lib/keyboard";
+import { useFocusTrap } from "@/hooks/use-focus-trap";
+import { cn } from "@/lib/utils";
+
+interface PaletteCommand {
+ id: string;
+ label: string;
+ icon: AppIcon;
+ /** Shown after the label: the plugin that added the entry. */
+ source?: string;
+ group: "navigation" | "actions" | "plugins";
+ run: () => void;
+}
+
+/** The action surface plugins declare palette entries on (api.ui.setActions). */
+const surface = "command-palette";
+
+/** Surfaces where the palette does not take Ctrl+K: the Pro shell uses it for global search. */
+const isProShell = (pathname: string) => pathname === "/pro" || pathname.startsWith("/pro/");
+
+/**
+ * The command palette (Ctrl+K / ⌘K): a searchable list of the app's own
+ * commands and of the entries plugins declare for `command-palette`.
+ * Mounted once in the locale layout; it only listens while signed in.
+ */
+export function CommandPaletteHost() {
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
+ const pathname = usePathname();
+ const [open, setOpen] = useState(false);
+ const [showShortcuts, setShowShortcuts] = useState(false);
+ const enabled = isAuthenticated && !isProShell(pathname);
+
+ useEffect(() => {
+ if (!enabled) return;
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey) return;
+ // event.code: the K key on any keyboard layout, Latin or not.
+ if (event.code !== "KeyK" && event.key.toLowerCase() !== "k") return;
+ if (isEditableEventTarget(event)) return;
+ event.preventDefault();
+ setOpen((v) => !v);
+ };
+ const onOpen = () => setOpen(true);
+ window.addEventListener("keydown", onKeyDown);
+ window.addEventListener(COMMAND_PALETTE_OPEN_EVENT, onOpen);
+ return () => {
+ window.removeEventListener("keydown", onKeyDown);
+ window.removeEventListener(COMMAND_PALETTE_OPEN_EVENT, onOpen);
+ };
+ }, [enabled]);
+
+ useEffect(() => {
+ if (!enabled) setOpen(false);
+ }, [enabled]);
+
+ return (
+ <>
+ {open && setOpen(false)} onShowShortcuts={() => setShowShortcuts(true)} />}
+ setShowShortcuts(false)} />
+ >
+ );
+}
+
+function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onShowShortcuts: () => void }) {
+ const t = useTranslations("command_palette");
+ const tSidebar = useTranslations("sidebar");
+ const router = useRouter();
+ const pathname = usePathname();
+ const listId = useId();
+ const optionId = (index: number) => `${listId}-option-${index}`;
+ const [query, setQuery] = useState("");
+ const [active, setActive] = useState(0);
+ const listRef = useRef(null);
+
+ const client = useAuthStore((s) => s.client);
+ const supportsCalendar = useCalendarStore((s) => s.supportsCalendar);
+ const isFeatureEnabled = usePolicyStore((s) => s.isFeatureEnabled);
+ const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
+ const pluginActions = usePluginActions(surface);
+
+ const dialogRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true });
+
+ const commands = useMemo(() => {
+ const go = (href: string) => () => router.push(href);
+ // Compose and search belong to the mail page: run there, or open mail first.
+ const onMail = (command: AppCommand) => () => {
+ if (requestAppCommand(command)) return;
+ parkAppCommand(command);
+ router.push("/");
+ };
+ const list: PaletteCommand[] = [
+ { id: "go-mail", group: "navigation", icon: Mail, label: t("go_to", { name: tSidebar("mail") }), run: go("/") },
+ ];
+ if (supportsCalendar && isFeatureEnabled("calendarEnabled")) {
+ list.push({ id: "go-calendar", group: "navigation", icon: Calendar, label: t("go_to", { name: tSidebar("calendar") }), run: go("/calendar") });
+ }
+ if ((client?.supportsContacts?.() ?? false) && isFeatureEnabled("contactsEnabled")) {
+ list.push({ id: "go-contacts", group: "navigation", icon: BookUser, label: t("go_to", { name: tSidebar("contacts") }), run: go("/contacts") });
+ }
+ if ((client?.supportsFiles?.() ?? false) && isFeatureEnabled("filesEnabled")) {
+ list.push({ id: "go-files", group: "navigation", icon: HardDrive, label: t("go_to", { name: tSidebar("files") }), run: go("/files") });
+ }
+ list.push(
+ { id: "go-settings", group: "navigation", icon: Settings, label: t("go_to", { name: tSidebar("settings") }), run: go("/settings") },
+ { id: "compose", group: "actions", icon: PenLine, label: t("compose"), run: onMail("compose") },
+ { id: "search", group: "actions", icon: Search, label: t("search"), run: onMail("search") },
+ {
+ id: "theme",
+ group: "actions",
+ icon: resolvedTheme === "dark" ? Sun : Moon,
+ label: resolvedTheme === "dark" ? t("theme_light") : t("theme_dark"),
+ run: () => useThemeStore.getState().setTheme(resolvedTheme === "dark" ? "light" : "dark"),
+ },
+ { id: "shortcuts", group: "actions", icon: Keyboard, label: t("shortcuts"), run: onShowShortcuts },
+ );
+ for (const action of pluginActions) {
+ list.push({
+ id: `plugin:${action.pluginId}:${action.id}`,
+ group: "plugins",
+ icon: iconForName(action.icon) ?? Puzzle,
+ label: action.label,
+ source: action.pluginName,
+ run: () => { void runAction(action, surface, {}); },
+ });
+ }
+ return list;
+ }, [client, supportsCalendar, isFeatureEnabled, resolvedTheme, pluginActions, router, t, tSidebar, onShowShortcuts]);
+
+ const needle = query.trim().toLowerCase();
+ const visible = needle
+ ? commands.filter((c) => c.label.toLowerCase().includes(needle) || (c.source?.toLowerCase().includes(needle) ?? false))
+ : commands;
+ const activeIndex = visible.length === 0 ? -1 : Math.min(active, visible.length - 1);
+
+ // Close when the page changes underneath (a "go to" command ran).
+ const startPath = useRef(pathname);
+ useEffect(() => {
+ if (pathname !== startPath.current) onClose();
+ }, [pathname, onClose]);
+
+ useEffect(() => {
+ if (activeIndex < 0) return;
+ const node = listRef.current?.querySelector(`[data-index="${activeIndex}"]`);
+ node?.scrollIntoView?.({ block: "nearest" });
+ }, [activeIndex]);
+
+ const runCommand = useCallback((command: PaletteCommand) => {
+ onClose();
+ command.run();
+ }, [onClose]);
+
+ const onKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ if (visible.length) setActive((activeIndex + 1) % visible.length);
+ } else if (event.key === "ArrowUp") {
+ event.preventDefault();
+ if (visible.length) setActive((activeIndex - 1 + visible.length) % visible.length);
+ } else if (event.key === "Home") {
+ event.preventDefault();
+ setActive(0);
+ } else if (event.key === "End") {
+ event.preventDefault();
+ setActive(Math.max(0, visible.length - 1));
+ } else if (event.key === "Enter") {
+ event.preventDefault();
+ if (activeIndex >= 0) runCommand(visible[activeIndex]);
+ } else if ((event.ctrlKey || event.metaKey) && (event.code === "KeyK" || event.key.toLowerCase() === "k")) {
+ event.preventDefault();
+ onClose();
+ }
+ };
+
+ const groupLabel = (group: PaletteCommand["group"]) =>
+ group === "navigation" ? t("group_navigation") : group === "actions" ? t("group_actions") : t("group_plugins");
+
+ return (
+ { if (e.target === e.currentTarget) onClose(); }}
+ >
+
+
+
+ = 0 ? optionId(activeIndex) : undefined}
+ aria-label={t("title")}
+ value={query}
+ onChange={(e) => { setQuery(e.target.value); setActive(0); }}
+ onKeyDown={onKeyDown}
+ placeholder={t("placeholder")}
+ autoComplete="off"
+ spellCheck={false}
+ className="h-12 flex-1 min-w-0 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
+ />
+ Esc
+
+
+ {visible.length === 0 && (
+ {t("no_results")}
+ )}
+ {visible.map((command, index) => {
+ const Icon = command.icon;
+ const showGroup = index === 0 || visible[index - 1].group !== command.group;
+ return (
+
+ {showGroup && (
+
+ {groupLabel(command.group)}
+
+ )}
+ { if (index !== activeIndex) setActive(index); }}
+ onMouseDown={(e) => e.preventDefault()}
+ onClick={() => runCommand(command)}
+ className={cn(
+ "mx-1 flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-sm max-sm:min-h-[44px]",
+ index === activeIndex ? "bg-muted text-foreground" : "text-foreground/90",
+ )}
+ >
+
+ {command.label}
+ {command.source && (
+ {command.source}
+ )}
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/components/keyboard-shortcuts-modal.tsx b/components/keyboard-shortcuts-modal.tsx
index bddd6041a..50bdea13e 100644
--- a/components/keyboard-shortcuts-modal.tsx
+++ b/components/keyboard-shortcuts-modal.tsx
@@ -7,6 +7,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { cn } from "@/lib/utils";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTour } from "@/components/tour/tour-provider";
+import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
interface KeyboardShortcutsModalProps {
isOpen: boolean;
@@ -17,9 +18,10 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod
const t = useTranslations();
const { startTour } = useTour();
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
- // Rows that belong to one interface layout are listed only under it.
+ const shell = useProInterfaceActive() ? "pro" : "standard";
+ // Rows that belong to one interface layout (or one shell) are listed only under it.
const shown = (entries: readonly ShortcutEntry[]) =>
- entries.filter((entry) => !entry.layout || entry.layout === interfaceLayout);
+ entries.filter((entry) => (!entry.layout || entry.layout === interfaceLayout) && (!entry.shell || entry.shell === shell));
const modalRef = useFocusTrap({
isActive: isOpen,
diff --git a/components/layout/__tests__/top-bar-plugin-actions.test.tsx b/components/layout/__tests__/top-bar-plugin-actions.test.tsx
index 3835b22cc..e3f09f61e 100644
--- a/components/layout/__tests__/top-bar-plugin-actions.test.tsx
+++ b/components/layout/__tests__/top-bar-plugin-actions.test.tsx
@@ -50,9 +50,16 @@ describe('Mountain View top bar', () => {
});
describe('phone header', () => {
- it('has no overflow menu when no plugin declared buttons', () => {
+ it('offers only the command palette in its menu when no plugin declared buttons', () => {
+ const opened = vi.fn();
+ window.addEventListener('bulwark:command-palette', opened);
render( );
- expect(screen.queryByRole('button', { name: 'more' })).toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: 'more' }));
+
+ expect(screen.getAllByRole('menuitem').map((m) => m.textContent)).toEqual(['title']);
+ fireEvent.click(screen.getByRole('menuitem', { name: 'title' }));
+ expect(opened).toHaveBeenCalled();
+ window.removeEventListener('bulwark:command-palette', opened);
});
it('lists plugin buttons in its overflow menu', async () => {
diff --git a/components/layout/mobile-header.tsx b/components/layout/mobile-header.tsx
index 288e6b78d..9fa7fbf64 100644
--- a/components/layout/mobile-header.tsx
+++ b/components/layout/mobile-header.tsx
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
-import { Menu, ArrowLeft, Plus, Search, X, MoreVertical } from "@/components/icons";
+import { Menu, ArrowLeft, Plus, Search, X, MoreVertical, Terminal } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { useUIStore } from "@/stores/ui-store";
import { useSettingsStore } from "@/stores/settings-store";
@@ -9,20 +9,22 @@ import { useIsDesktop } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { AccountSwitcher } from "@/components/layout/account-switcher";
-import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
+import { PluginActions } from "@/components/plugins/plugin-actions";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
+import { ContextMenuItem } from "@/components/ui/context-menu";
+import { openCommandPalette } from "@/lib/app-commands";
/** Plugin buttons in the top bar are not about any one item. */
const TOP_BAR_CONTEXT: Record = {};
/**
- * The phone header's overflow menu: what the desktop top bar shows as
- * buttons (plugin top-bar-actions) and has no room for here. Renders nothing
- * while it would be empty, so the header keeps its layout without plugins.
+ * The phone header's overflow menu: the command palette (no Ctrl+K on a
+ * phone) and what the desktop top bar shows as buttons (plugin
+ * top-bar-actions) and has no room for here.
*/
function MobileHeaderMenu() {
const t = useTranslations('top_bar');
- const pluginActions = usePluginActions('top-bar-actions');
+ const tPalette = useTranslations('command_palette');
const [open, setOpen] = useState(false);
const triggerRef = useRef(null);
const close = useCallback(() => setOpen(false), []);
@@ -38,8 +40,6 @@ function MobileHeaderMenu() {
return () => document.removeEventListener('mousedown', onPointerDown);
}, [open, menuRef]);
- if (pluginActions.length === 0) return null;
-
return (
+ { close(); openCommandPalette(); }}
+ />
)}
diff --git a/components/mail/__tests__/mail-app-top-bar-actions.test.tsx b/components/mail/__tests__/mail-app-top-bar-actions.test.tsx
index c131de9dd..1cebde9a0 100644
--- a/components/mail/__tests__/mail-app-top-bar-actions.test.tsx
+++ b/components/mail/__tests__/mail-app-top-bar-actions.test.tsx
@@ -103,3 +103,20 @@ describe('top-bar-actions in the standard layout', () => {
expect(container.querySelector('[data-plugin-actions="top-bar-actions"]')).toBeNull();
});
});
+
+describe('command palette commands on the mail page', () => {
+ it('takes compose from the palette and opens the composer', async () => {
+ const { requestAppCommand } = await import('@/lib/app-commands');
+ render( );
+ let taken = false;
+ act(() => { taken = requestAppCommand('compose'); });
+ expect(taken).toBe(true);
+ });
+
+ it('runs a search parked before the page was ready', async () => {
+ const { parkAppCommand, peekParkedAppCommand } = await import('@/lib/app-commands');
+ parkAppCommand('search');
+ render( );
+ expect(peekParkedAppCommand()).toBeNull();
+ });
+});
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 6ea61f7b8..b1f993a90 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -83,6 +83,7 @@ import { WelcomeBanner } from "@/components/ui/welcome-banner";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { MvTopBar } from "@/components/layout/mv-top-bar";
import { PluginActions } from "@/components/plugins/plugin-actions";
+import { useAppCommands } from "@/lib/app-commands";
import { MvSearchForm } from "@/components/search/mv-search-form";
import { MvSidePanel } from "@/components/layout/mv-side-panel";
import { MvListToolbar } from "@/components/email/mv-list-toolbar";
@@ -934,6 +935,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
handlers: keyboardHandlers,
});
+ // Compose and search from the command palette (components/command-palette).
+ useAppCommands({
+ compose: keyboardHandlers.onCompose,
+ search: () => {
+ if (isMobile) setMobileSearchOpen(true);
+ else keyboardHandlers.onFocusSearch?.();
+ },
+ }, isAuthenticated && !!client);
+
// Shared mail refresh: re-fetch mailboxes (unread counts) + the active list
// via JMAP. Used by both the browser refresh gestures and the toolbar button.
const [isManualRefreshing, setIsManualRefreshing] = useState(false);
diff --git a/hooks/use-keyboard-shortcuts.ts b/hooks/use-keyboard-shortcuts.ts
index 9e9c007c9..8c8579114 100644
--- a/hooks/use-keyboard-shortcuts.ts
+++ b/hooks/use-keyboard-shortcuts.ts
@@ -364,6 +364,8 @@ export interface ShortcutEntry {
readonly key: string;
readonly description: string;
readonly layout?: InterfaceLayout;
+ /** Listed only in the Pro shell, or only outside it. */
+ readonly shell?: "pro" | "standard";
}
// Shortcut definitions for the help modal
@@ -389,7 +391,8 @@ export const KEYBOARD_SHORTCUTS = {
global: [
{ key: "c", description: "shortcuts.global.compose" },
{ key: "/", description: "shortcuts.global.search" },
- { key: "Ctrl/⌘ + K", description: "shortcuts.global.global_search" },
+ { key: "Ctrl/⌘ + K", description: "shortcuts.global.global_search", shell: "pro" },
+ { key: "Ctrl/⌘ + K", description: "command_palette.open", shell: "standard" },
{ key: "?", description: "shortcuts.global.help" },
{ key: "Shift + G", description: "shortcuts.global.refresh" },
{ key: "Ctrl + A", description: "shortcuts.global.select_all" },
diff --git a/lib/__tests__/app-commands.test.tsx b/lib/__tests__/app-commands.test.tsx
new file mode 100644
index 000000000..898b1d649
--- /dev/null
+++ b/lib/__tests__/app-commands.test.tsx
@@ -0,0 +1,38 @@
+import { render } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { parkAppCommand, peekParkedAppCommand, requestAppCommand, useAppCommands } from '../app-commands';
+
+function Surface({ compose, enabled = true }: { compose: () => void; enabled?: boolean }) {
+ useAppCommands({ compose }, enabled);
+ return null;
+}
+
+describe('palette commands for the mail page', () => {
+ it('reports a command nobody took', () => {
+ expect(requestAppCommand('compose')).toBe(false);
+ });
+
+ it('runs a command on the mounted surface', () => {
+ const compose = vi.fn();
+ render( );
+ expect(requestAppCommand('compose')).toBe(true);
+ expect(compose).toHaveBeenCalledTimes(1);
+ // A command the surface has no handler for stays untaken.
+ expect(requestAppCommand('search')).toBe(false);
+ });
+
+ it('runs a parked command once the surface is ready, and only once', () => {
+ const compose = vi.fn();
+ parkAppCommand('compose');
+ const { rerender } = render( );
+ expect(compose).not.toHaveBeenCalled();
+
+ rerender( );
+ expect(compose).toHaveBeenCalledTimes(1);
+ expect(peekParkedAppCommand()).toBeNull();
+
+ rerender( );
+ rerender( );
+ expect(compose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/lib/app-commands.ts b/lib/app-commands.ts
new file mode 100644
index 000000000..c3bad3e1b
--- /dev/null
+++ b/lib/app-commands.ts
@@ -0,0 +1,66 @@
+"use client";
+
+// Commands the command palette hands to the surface that can run them (the
+// mail page composes and searches). Loosely coupled through a window event,
+// like the in-app mailto: requests, because that state lives deep inside the
+// page component. When no surface takes a command, the palette parks it and
+// navigates to mail, which runs it once it is ready.
+
+import { useEffect, useRef } from 'react';
+
+export type AppCommand = 'compose' | 'search';
+
+export const COMMAND_PALETTE_OPEN_EVENT = 'bulwark:command-palette';
+
+/** Open the command palette from a button or menu (phones have no Ctrl+K). */
+export function openCommandPalette(): void {
+ if (typeof window !== 'undefined') window.dispatchEvent(new Event(COMMAND_PALETTE_OPEN_EVENT));
+}
+
+const EVENT = 'bulwark:app-command';
+let pending: AppCommand | null = null;
+
+/** Run a command on a mounted surface. False when nothing took it. */
+export function requestAppCommand(command: AppCommand): boolean {
+ if (typeof window === 'undefined') return false;
+ const uncancelled = window.dispatchEvent(new CustomEvent(EVENT, { detail: command, cancelable: true }));
+ return !uncancelled;
+}
+
+/** Keep a command for the next surface that registers a handler for it. */
+export function parkAppCommand(command: AppCommand): void {
+ pending = command;
+}
+
+/** Test helper. */
+export function peekParkedAppCommand(): AppCommand | null {
+ return pending;
+}
+
+/**
+ * Let this surface run palette commands while `enabled`. A command parked
+ * before the surface was ready runs as soon as it is.
+ */
+export function useAppCommands(handlers: Partial void>>, enabled: boolean): void {
+ const ref = useRef(handlers);
+ useEffect(() => {
+ ref.current = handlers;
+ });
+
+ useEffect(() => {
+ if (!enabled) return;
+ const onCommand = (event: Event) => {
+ const handler = ref.current[(event as CustomEvent).detail];
+ if (!handler) return;
+ event.preventDefault();
+ handler();
+ };
+ window.addEventListener(EVENT, onCommand);
+ if (pending && ref.current[pending]) {
+ const handler = ref.current[pending]!;
+ pending = null;
+ handler();
+ }
+ return () => window.removeEventListener(EVENT, onCommand);
+ }, [enabled]);
+}
diff --git a/locales/ar/common.json b/locales/ar/common.json
index c7b929ad1..f48703e28 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "الإضافة التي أضافتها متوقفة أو لم تعد مثبتة.",
"back_to_mail": "العودة إلى البريد",
"settings_group": "الإضافات"
+ },
+ "command_palette": {
+ "title": "لوحة الأوامر",
+ "open": "فتح لوحة الأوامر",
+ "placeholder": "اكتب أمرًا",
+ "no_results": "لا توجد أوامر مطابقة",
+ "go_to": "الانتقال إلى {name}",
+ "compose": "رسالة جديدة",
+ "search": "البحث في البريد",
+ "theme_dark": "التبديل إلى المظهر الداكن",
+ "theme_light": "التبديل إلى المظهر الفاتح",
+ "shortcuts": "عرض اختصارات لوحة المفاتيح",
+ "group_navigation": "الانتقال إلى",
+ "group_actions": "إجراءات",
+ "group_plugins": "الإضافات"
}
}
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 4ccf50925..b79cc2fc1 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "El connector que l'ha afegida està desactivat o ja no està instal·lat.",
"back_to_mail": "Torna al correu",
"settings_group": "Connectors"
+ },
+ "command_palette": {
+ "title": "Paleta d'ordres",
+ "open": "Obre la paleta d'ordres",
+ "placeholder": "Escriviu una ordre",
+ "no_results": "Cap ordre coincident",
+ "go_to": "Ves a {name}",
+ "compose": "Missatge nou",
+ "search": "Cerca al correu",
+ "theme_dark": "Canvia al tema fosc",
+ "theme_light": "Canvia al tema clar",
+ "shortcuts": "Mostra les dreceres de teclat",
+ "group_navigation": "Ves a",
+ "group_actions": "Accions",
+ "group_plugins": "Connectors"
}
}
diff --git a/locales/cs/common.json b/locales/cs/common.json
index db973baee..97d070dcd 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "Plugin, který ji přidal, je vypnutý nebo už není nainstalovaný.",
"back_to_mail": "Zpět na poštu",
"settings_group": "Pluginy"
+ },
+ "command_palette": {
+ "title": "Paleta příkazů",
+ "open": "Otevřít paletu příkazů",
+ "placeholder": "Zadejte příkaz",
+ "no_results": "Žádné odpovídající příkazy",
+ "go_to": "Přejít na {name}",
+ "compose": "Nová zpráva",
+ "search": "Hledat v poště",
+ "theme_dark": "Přepnout na tmavý motiv",
+ "theme_light": "Přepnout na světlý motiv",
+ "shortcuts": "Zobrazit klávesové zkratky",
+ "group_navigation": "Přejít na",
+ "group_actions": "Akce",
+ "group_plugins": "Pluginy"
}
}
diff --git a/locales/da/common.json b/locales/da/common.json
index a10f232d6..362f80467 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "Det plugin, der tilføjede den, er slået fra eller ikke længere installeret.",
"back_to_mail": "Tilbage til mail",
"settings_group": "Plugins"
+ },
+ "command_palette": {
+ "title": "Kommandopalet",
+ "open": "Åbn kommandopaletten",
+ "placeholder": "Skriv en kommando",
+ "no_results": "Ingen matchende kommandoer",
+ "go_to": "Gå til {name}",
+ "compose": "Ny besked",
+ "search": "Søg i mail",
+ "theme_dark": "Skift til mørkt tema",
+ "theme_light": "Skift til lyst tema",
+ "shortcuts": "Vis tastaturgenveje",
+ "group_navigation": "Gå til",
+ "group_actions": "Handlinger",
+ "group_plugins": "Plugins"
}
}
diff --git a/locales/de/common.json b/locales/de/common.json
index 5e92726e5..997d4e2a0 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "Das Plugin, das sie hinzugefügt hat, ist ausgeschaltet oder nicht mehr installiert.",
"back_to_mail": "Zurück zu E-Mail",
"settings_group": "Plugins"
+ },
+ "command_palette": {
+ "title": "Befehlspalette",
+ "open": "Befehlspalette öffnen",
+ "placeholder": "Befehl eingeben",
+ "no_results": "Keine passenden Befehle",
+ "go_to": "Zu {name} wechseln",
+ "compose": "Neue Nachricht",
+ "search": "E-Mails durchsuchen",
+ "theme_dark": "Dunkles Design verwenden",
+ "theme_light": "Helles Design verwenden",
+ "shortcuts": "Tastaturkürzel anzeigen",
+ "group_navigation": "Wechseln zu",
+ "group_actions": "Aktionen",
+ "group_plugins": "Plugins"
}
}
diff --git a/locales/en/common.json b/locales/en/common.json
index 19efd2e2d..169125524 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "The plugin that added it is turned off or no longer installed.",
"back_to_mail": "Back to Mail",
"settings_group": "Plugins"
+ },
+ "command_palette": {
+ "title": "Command palette",
+ "open": "Open the command palette",
+ "placeholder": "Type a command",
+ "no_results": "No matching commands",
+ "go_to": "Go to {name}",
+ "compose": "New message",
+ "search": "Search mail",
+ "theme_dark": "Switch to dark theme",
+ "theme_light": "Switch to light theme",
+ "shortcuts": "Show keyboard shortcuts",
+ "group_navigation": "Go to",
+ "group_actions": "Actions",
+ "group_plugins": "Plugins"
}
}
diff --git a/locales/es/common.json b/locales/es/common.json
index 1a8dca501..e47767b5a 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "El complemento que la añadió está desactivado o ya no está instalado.",
"back_to_mail": "Volver al correo",
"settings_group": "Complementos"
+ },
+ "command_palette": {
+ "title": "Paleta de comandos",
+ "open": "Abrir la paleta de comandos",
+ "placeholder": "Escribe un comando",
+ "no_results": "No hay comandos que coincidan",
+ "go_to": "Ir a {name}",
+ "compose": "Mensaje nuevo",
+ "search": "Buscar en el correo",
+ "theme_dark": "Cambiar al tema oscuro",
+ "theme_light": "Cambiar al tema claro",
+ "shortcuts": "Mostrar atajos de teclado",
+ "group_navigation": "Ir a",
+ "group_actions": "Acciones",
+ "group_plugins": "Complementos"
}
}
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 528937cf9..dca1b790a 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "افزونهای که آن را اضافه کرده خاموش است یا دیگر نصب نیست.",
"back_to_mail": "بازگشت به ایمیل",
"settings_group": "افزونهها"
+ },
+ "command_palette": {
+ "title": "پالت فرمان",
+ "open": "باز کردن پالت فرمان",
+ "placeholder": "یک فرمان بنویسید",
+ "no_results": "فرمان منطبقی پیدا نشد",
+ "go_to": "رفتن به {name}",
+ "compose": "پیام جدید",
+ "search": "جستجو در ایمیل",
+ "theme_dark": "رفتن به پوسته تیره",
+ "theme_light": "رفتن به پوسته روشن",
+ "shortcuts": "نمایش میانبرهای صفحه کلید",
+ "group_navigation": "رفتن به",
+ "group_actions": "کارها",
+ "group_plugins": "افزونهها"
}
}
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 2e0ae2324..0134c04fd 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "Le plugin qui l'a ajoutée est désactivé ou n'est plus installé.",
"back_to_mail": "Retour à la messagerie",
"settings_group": "Plugins"
+ },
+ "command_palette": {
+ "title": "Palette de commandes",
+ "open": "Ouvrir la palette de commandes",
+ "placeholder": "Tapez une commande",
+ "no_results": "Aucune commande correspondante",
+ "go_to": "Aller à {name}",
+ "compose": "Nouveau message",
+ "search": "Rechercher dans la messagerie",
+ "theme_dark": "Passer au thème sombre",
+ "theme_light": "Passer au thème clair",
+ "shortcuts": "Afficher les raccourcis clavier",
+ "group_navigation": "Aller à",
+ "group_actions": "Actions",
+ "group_plugins": "Plugins"
}
}
diff --git a/locales/he/common.json b/locales/he/common.json
index f4a4245dc..b3b103e50 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -4351,5 +4351,20 @@
"not_available_description": "התוסף שהוסיף אותו כבוי או שאינו מותקן עוד.",
"back_to_mail": "חזרה לדואר",
"settings_group": "תוספים"
+ },
+ "command_palette": {
+ "title": "לוח פקודות",
+ "open": "פתיחת לוח הפקודות",
+ "placeholder": "הקלידו פקודה",
+ "no_results": "אין פקודות תואמות",
+ "go_to": "מעבר אל {name}",
+ "compose": "הודעה חדשה",
+ "search": "חיפוש בדואר",
+ "theme_dark": "מעבר לערכת נושא כהה",
+ "theme_light": "מעבר לערכת נושא בהירה",
+ "shortcuts": "הצגת קיצורי מקשים",
+ "group_navigation": "מעבר אל",
+ "group_actions": "פעולות",
+ "group_plugins": "תוספים"
}
}
diff --git a/locales/hu/common.json b/locales/hu/common.json
index ade921b05..40b016251 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "A bővítmény, amely hozzáadta, ki van kapcsolva, vagy már nincs telepítve.",
"back_to_mail": "Vissza a levelekhez",
"settings_group": "Bővítmények"
+ },
+ "command_palette": {
+ "title": "Parancspaletta",
+ "open": "Parancspaletta megnyitása",
+ "placeholder": "Írjon be egy parancsot",
+ "no_results": "Nincs egyező parancs",
+ "go_to": "Ugrás ide: {name}",
+ "compose": "Új levél",
+ "search": "Keresés a levelekben",
+ "theme_dark": "Váltás sötét témára",
+ "theme_light": "Váltás világos témára",
+ "shortcuts": "Billentyűparancsok megjelenítése",
+ "group_navigation": "Ugrás ide",
+ "group_actions": "Műveletek",
+ "group_plugins": "Bővítmények"
}
}
diff --git a/locales/it/common.json b/locales/it/common.json
index 09b71a121..35edc4398 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "Il plugin che l'ha aggiunta è disattivato o non è più installato.",
"back_to_mail": "Torna alla posta",
"settings_group": "Plugin"
+ },
+ "command_palette": {
+ "title": "Tavolozza comandi",
+ "open": "Apri la tavolozza comandi",
+ "placeholder": "Digita un comando",
+ "no_results": "Nessun comando corrispondente",
+ "go_to": "Vai a {name}",
+ "compose": "Nuovo messaggio",
+ "search": "Cerca nella posta",
+ "theme_dark": "Passa al tema scuro",
+ "theme_light": "Passa al tema chiaro",
+ "shortcuts": "Mostra le scorciatoie da tastiera",
+ "group_navigation": "Vai a",
+ "group_actions": "Azioni",
+ "group_plugins": "Plugin"
}
}
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 18ec80be7..053c04031 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "このページを追加したプラグインがオフになっているか、インストールされていません。",
"back_to_mail": "メールに戻る",
"settings_group": "プラグイン"
+ },
+ "command_palette": {
+ "title": "コマンドパレット",
+ "open": "コマンドパレットを開く",
+ "placeholder": "コマンドを入力",
+ "no_results": "一致するコマンドはありません",
+ "go_to": "{name}に移動",
+ "compose": "新規メッセージ",
+ "search": "メールを検索",
+ "theme_dark": "ダークテーマに切り替え",
+ "theme_light": "ライトテーマに切り替え",
+ "shortcuts": "キーボードショートカットを表示",
+ "group_navigation": "移動",
+ "group_actions": "操作",
+ "group_plugins": "プラグイン"
}
}
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 574296434..643bb7558 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "이 페이지를 추가한 플러그인이 꺼져 있거나 더 이상 설치되어 있지 않습니다.",
"back_to_mail": "메일로 돌아가기",
"settings_group": "플러그인"
+ },
+ "command_palette": {
+ "title": "명령 팔레트",
+ "open": "명령 팔레트 열기",
+ "placeholder": "명령을 입력하세요",
+ "no_results": "일치하는 명령이 없습니다",
+ "go_to": "{name}(으)로 이동",
+ "compose": "새 메일",
+ "search": "메일 검색",
+ "theme_dark": "어두운 테마로 전환",
+ "theme_light": "밝은 테마로 전환",
+ "shortcuts": "단축키 보기",
+ "group_navigation": "이동",
+ "group_actions": "작업",
+ "group_plugins": "플러그인"
}
}
diff --git a/locales/lv/common.json b/locales/lv/common.json
index c1aaa5b39..e2cb18184 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "Spraudnis, kas to pievienoja, ir izslēgts vai vairs nav instalēts.",
"back_to_mail": "Atpakaļ uz pastu",
"settings_group": "Spraudņi"
+ },
+ "command_palette": {
+ "title": "Komandu palete",
+ "open": "Atvērt komandu paleti",
+ "placeholder": "Ierakstiet komandu",
+ "no_results": "Nav atbilstošu komandu",
+ "go_to": "Pāriet uz: {name}",
+ "compose": "Jauna vēstule",
+ "search": "Meklēt pastā",
+ "theme_dark": "Pārslēgt uz tumšo motīvu",
+ "theme_light": "Pārslēgt uz gaišo motīvu",
+ "shortcuts": "Rādīt tastatūras īsinājumtaustiņus",
+ "group_navigation": "Pāriet uz",
+ "group_actions": "Darbības",
+ "group_plugins": "Spraudņi"
}
}
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 8cb5de007..b4e7e9f92 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "Үүнийг нэмсэн залгаас унтарсан эсвэл суулгаагүй болсон байна.",
"back_to_mail": "Мэйл рүү буцах",
"settings_group": "Залгаасууд"
+ },
+ "command_palette": {
+ "title": "Командын самбар",
+ "open": "Командын самбарыг нээх",
+ "placeholder": "Команд бичнэ үү",
+ "no_results": "Тохирох команд алга",
+ "go_to": "{name} руу очих",
+ "compose": "Шинэ захидал",
+ "search": "Мэйлээс хайх",
+ "theme_dark": "Бараан загвар руу шилжих",
+ "theme_light": "Цайвар загвар руу шилжих",
+ "shortcuts": "Гарын товчлолыг харуулах",
+ "group_navigation": "Очих",
+ "group_actions": "Үйлдлүүд",
+ "group_plugins": "Залгаасууд"
}
}
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 265a29f23..c1ff00c11 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "Tillegget som la den til, er slått av eller ikke lenger installert.",
"back_to_mail": "Tilbake til e-post",
"settings_group": "Tillegg"
+ },
+ "command_palette": {
+ "title": "Kommandopalett",
+ "open": "Åpne kommandopaletten",
+ "placeholder": "Skriv en kommando",
+ "no_results": "Ingen samsvarende kommandoer",
+ "go_to": "Gå til {name}",
+ "compose": "Ny melding",
+ "search": "Søk i e-post",
+ "theme_dark": "Bytt til mørkt tema",
+ "theme_light": "Bytt til lyst tema",
+ "shortcuts": "Vis tastatursnarveier",
+ "group_navigation": "Gå til",
+ "group_actions": "Handlinger",
+ "group_plugins": "Tillegg"
}
}
diff --git a/locales/nl/common.json b/locales/nl/common.json
index cc09602ff..4596218a5 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "De plug-in die hem heeft toegevoegd, staat uit of is niet meer geïnstalleerd.",
"back_to_mail": "Terug naar e-mail",
"settings_group": "Plug-ins"
+ },
+ "command_palette": {
+ "title": "Opdrachtenpalet",
+ "open": "Opdrachtenpalet openen",
+ "placeholder": "Typ een opdracht",
+ "no_results": "Geen overeenkomende opdrachten",
+ "go_to": "Naar {name}",
+ "compose": "Nieuw bericht",
+ "search": "E-mail doorzoeken",
+ "theme_dark": "Donker thema gebruiken",
+ "theme_light": "Licht thema gebruiken",
+ "shortcuts": "Sneltoetsen tonen",
+ "group_navigation": "Naar",
+ "group_actions": "Acties",
+ "group_plugins": "Plug-ins"
}
}
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 33ab46d13..c38cf8627 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "Wtyczka, która ją dodała, jest wyłączona lub nie jest już zainstalowana.",
"back_to_mail": "Wróć do poczty",
"settings_group": "Wtyczki"
+ },
+ "command_palette": {
+ "title": "Paleta poleceń",
+ "open": "Otwórz paletę poleceń",
+ "placeholder": "Wpisz polecenie",
+ "no_results": "Brak pasujących poleceń",
+ "go_to": "Przejdź do: {name}",
+ "compose": "Nowa wiadomość",
+ "search": "Szukaj w poczcie",
+ "theme_dark": "Przełącz na ciemny motyw",
+ "theme_light": "Przełącz na jasny motyw",
+ "shortcuts": "Pokaż skróty klawiszowe",
+ "group_navigation": "Przejdź do",
+ "group_actions": "Działania",
+ "group_plugins": "Wtyczki"
}
}
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 590212fe3..7642d9559 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "O plugin que a adicionou está desativado ou não está mais instalado.",
"back_to_mail": "Voltar ao e-mail",
"settings_group": "Plugins"
+ },
+ "command_palette": {
+ "title": "Paleta de comandos",
+ "open": "Abrir a paleta de comandos",
+ "placeholder": "Digite um comando",
+ "no_results": "Nenhum comando correspondente",
+ "go_to": "Ir para {name}",
+ "compose": "Nova mensagem",
+ "search": "Pesquisar no e-mail",
+ "theme_dark": "Mudar para o tema escuro",
+ "theme_light": "Mudar para o tema claro",
+ "shortcuts": "Mostrar atalhos de teclado",
+ "group_navigation": "Ir para",
+ "group_actions": "Ações",
+ "group_plugins": "Plugins"
}
}
diff --git a/locales/ro/common.json b/locales/ro/common.json
index a196ba6da..9dfb217b9 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "Pluginul care a adăugat-o este dezactivat sau nu mai este instalat.",
"back_to_mail": "Înapoi la e-mail",
"settings_group": "Pluginuri"
+ },
+ "command_palette": {
+ "title": "Paleta de comenzi",
+ "open": "Deschide paleta de comenzi",
+ "placeholder": "Tastați o comandă",
+ "no_results": "Nicio comandă potrivită",
+ "go_to": "Mergi la {name}",
+ "compose": "Mesaj nou",
+ "search": "Caută în e-mail",
+ "theme_dark": "Comută la tema întunecată",
+ "theme_light": "Comută la tema luminoasă",
+ "shortcuts": "Afișează comenzile rapide de la tastatură",
+ "group_navigation": "Mergi la",
+ "group_actions": "Acțiuni",
+ "group_plugins": "Pluginuri"
}
}
diff --git a/locales/ru/common.json b/locales/ru/common.json
index ddaf252cf..86468538d 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "Плагин, который её добавил, выключен или больше не установлен.",
"back_to_mail": "Вернуться к почте",
"settings_group": "Плагины"
+ },
+ "command_palette": {
+ "title": "Палитра команд",
+ "open": "Открыть палитру команд",
+ "placeholder": "Введите команду",
+ "no_results": "Нет подходящих команд",
+ "go_to": "Перейти: {name}",
+ "compose": "Новое письмо",
+ "search": "Искать в почте",
+ "theme_dark": "Включить тёмную тему",
+ "theme_light": "Включить светлую тему",
+ "shortcuts": "Показать сочетания клавиш",
+ "group_navigation": "Перейти",
+ "group_actions": "Действия",
+ "group_plugins": "Плагины"
}
}
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 10e6a696d..08666a759 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "Doplnok, ktorý ju pridal, je vypnutý alebo už nie je nainštalovaný.",
"back_to_mail": "Späť na poštu",
"settings_group": "Doplnky"
+ },
+ "command_palette": {
+ "title": "Paleta príkazov",
+ "open": "Otvoriť paletu príkazov",
+ "placeholder": "Zadajte príkaz",
+ "no_results": "Žiadne zodpovedajúce príkazy",
+ "go_to": "Prejsť na {name}",
+ "compose": "Nová správa",
+ "search": "Hľadať v pošte",
+ "theme_dark": "Prepnúť na tmavý motív",
+ "theme_light": "Prepnúť na svetlý motív",
+ "shortcuts": "Zobraziť klávesové skratky",
+ "group_navigation": "Prejsť na",
+ "group_actions": "Akcie",
+ "group_plugins": "Doplnky"
}
}
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 07b5cfdf9..4d5180fbd 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "Bu sayfayı ekleyen eklenti kapalı veya artık yüklü değil.",
"back_to_mail": "Postaya dön",
"settings_group": "Eklentiler"
+ },
+ "command_palette": {
+ "title": "Komut paleti",
+ "open": "Komut paletini aç",
+ "placeholder": "Bir komut yazın",
+ "no_results": "Eşleşen komut yok",
+ "go_to": "{name} bölümüne git",
+ "compose": "Yeni ileti",
+ "search": "Postada ara",
+ "theme_dark": "Koyu temaya geç",
+ "theme_light": "Açık temaya geç",
+ "shortcuts": "Klavye kısayollarını göster",
+ "group_navigation": "Git",
+ "group_actions": "Eylemler",
+ "group_plugins": "Eklentiler"
}
}
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 9a4a3ac34..321128785 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "Плагін, який її додав, вимкнено або більше не встановлено.",
"back_to_mail": "Повернутися до пошти",
"settings_group": "Плагіни"
+ },
+ "command_palette": {
+ "title": "Палітра команд",
+ "open": "Відкрити палітру команд",
+ "placeholder": "Введіть команду",
+ "no_results": "Немає відповідних команд",
+ "go_to": "Перейти: {name}",
+ "compose": "Новий лист",
+ "search": "Шукати в пошті",
+ "theme_dark": "Увімкнути темну тему",
+ "theme_light": "Увімкнути світлу тему",
+ "shortcuts": "Показати комбінації клавіш",
+ "group_navigation": "Перейти",
+ "group_actions": "Дії",
+ "group_plugins": "Плагіни"
}
}
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 5921f9d64..66991e896 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "新增此頁面的外掛程式已關閉或已不再安裝。",
"back_to_mail": "返回郵件",
"settings_group": "外掛程式"
+ },
+ "command_palette": {
+ "title": "命令面板",
+ "open": "開啟命令面板",
+ "placeholder": "輸入命令",
+ "no_results": "沒有相符的命令",
+ "go_to": "前往{name}",
+ "compose": "新郵件",
+ "search": "搜尋郵件",
+ "theme_dark": "切換為深色主題",
+ "theme_light": "切換為淺色主題",
+ "shortcuts": "顯示鍵盤快速鍵",
+ "group_navigation": "前往",
+ "group_actions": "動作",
+ "group_plugins": "外掛程式"
}
}
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 56b1d091f..dea411efc 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -4350,5 +4350,20 @@
"not_available_description": "添加此页面的插件已关闭或已不再安装。",
"back_to_mail": "返回邮件",
"settings_group": "插件"
+ },
+ "command_palette": {
+ "title": "命令面板",
+ "open": "打开命令面板",
+ "placeholder": "输入命令",
+ "no_results": "没有匹配的命令",
+ "go_to": "前往{name}",
+ "compose": "新邮件",
+ "search": "搜索邮件",
+ "theme_dark": "切换到深色主题",
+ "theme_light": "切换到浅色主题",
+ "shortcuts": "显示键盘快捷键",
+ "group_navigation": "前往",
+ "group_actions": "操作",
+ "group_plugins": "插件"
}
}
From 8bf4df45138eecb69b324e2021cd89166df4fc15 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 14 Oct 2026 05:25:11 +0200
Subject: [PATCH 6/7] feat: add a notifications panel for plugin notifications
and app errors
---
.../__tests__/top-bar-plugin-actions.test.tsx | 7 +-
components/layout/mobile-header.tsx | 26 +-
components/layout/mv-top-bar.tsx | 2 +
components/layout/navigation-rail.tsx | 3 +
.../__tests__/notification-center.test.tsx | 121 ++++++++
.../notifications/notification-center.tsx | 262 ++++++++++++++++++
.../plugin-host-notifications.test.ts | 27 ++
lib/plugin-sandbox/host-api-notify.ts | 4 +
locales/ar/common.json | 11 +
locales/ca/common.json | 11 +
locales/cs/common.json | 11 +
locales/da/common.json | 11 +
locales/de/common.json | 11 +
locales/en/common.json | 11 +
locales/es/common.json | 11 +
locales/fa/common.json | 11 +
locales/fr/common.json | 11 +
locales/he/common.json | 11 +
locales/hu/common.json | 11 +
locales/it/common.json | 11 +
locales/ja/common.json | 11 +
locales/ko/common.json | 11 +
locales/lv/common.json | 11 +
locales/mn/common.json | 11 +
locales/nb/common.json | 11 +
locales/nl/common.json | 11 +
locales/pl/common.json | 11 +
locales/pt/common.json | 11 +
locales/ro/common.json | 11 +
locales/ru/common.json | 11 +
locales/sk/common.json | 11 +
locales/tr/common.json | 11 +
locales/uk/common.json | 11 +
locales/zh-TW/common.json | 11 +
locales/zh/common.json | 11 +
.../notification-center-store.test.ts | 70 +++++
stores/notification-center-store.ts | 114 ++++++++
stores/toast-store.ts | 5 +
38 files changed, 930 insertions(+), 8 deletions(-)
create mode 100644 components/notifications/__tests__/notification-center.test.tsx
create mode 100644 components/notifications/notification-center.tsx
create mode 100644 stores/__tests__/notification-center-store.test.ts
create mode 100644 stores/notification-center-store.ts
diff --git a/components/layout/__tests__/top-bar-plugin-actions.test.tsx b/components/layout/__tests__/top-bar-plugin-actions.test.tsx
index e3f09f61e..d4e4c48ae 100644
--- a/components/layout/__tests__/top-bar-plugin-actions.test.tsx
+++ b/components/layout/__tests__/top-bar-plugin-actions.test.tsx
@@ -56,8 +56,11 @@ describe('phone header', () => {
render( );
fireEvent.click(screen.getByRole('button', { name: 'more' }));
- expect(screen.getAllByRole('menuitem').map((m) => m.textContent)).toEqual(['title']);
- fireEvent.click(screen.getByRole('menuitem', { name: 'title' }));
+ // Notifications and the command palette (both labelled "title" here, as
+ // the translations are keys in tests); no plugin entries.
+ const items = screen.getAllByRole('menuitem');
+ expect(items.map((m) => m.textContent)).toEqual(['title', 'title']);
+ fireEvent.click(items[1]);
expect(opened).toHaveBeenCalled();
window.removeEventListener('bulwark:command-palette', opened);
});
diff --git a/components/layout/mobile-header.tsx b/components/layout/mobile-header.tsx
index 9fa7fbf64..3dc6db4cd 100644
--- a/components/layout/mobile-header.tsx
+++ b/components/layout/mobile-header.tsx
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
-import { Menu, ArrowLeft, Plus, Search, X, MoreVertical, Terminal } from "@/components/icons";
+import { Menu, ArrowLeft, Plus, Search, X, MoreVertical, Terminal, Bell } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { useUIStore } from "@/stores/ui-store";
import { useSettingsStore } from "@/stores/settings-store";
@@ -13,18 +13,22 @@ import { PluginActions } from "@/components/plugins/plugin-actions";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { ContextMenuItem } from "@/components/ui/context-menu";
import { openCommandPalette } from "@/lib/app-commands";
+import { NotificationSheet, formatBadgeCount, useUnreadNotificationCount } from "@/components/notifications/notification-center";
/** Plugin buttons in the top bar are not about any one item. */
const TOP_BAR_CONTEXT: Record = {};
/**
- * The phone header's overflow menu: the command palette (no Ctrl+K on a
- * phone) and what the desktop top bar shows as buttons (plugin
- * top-bar-actions) and has no room for here.
+ * The phone header's overflow menu: the notifications (the desktop bell), the
+ * command palette (no Ctrl+K on a phone) and what the desktop top bar shows
+ * as buttons (plugin top-bar-actions) and has no room for here.
*/
function MobileHeaderMenu() {
const t = useTranslations('top_bar');
const tPalette = useTranslations('command_palette');
+ const tNotifications = useTranslations('notification_center');
+ const unread = useUnreadNotificationCount();
+ const [showNotifications, setShowNotifications] = useState(false);
const [open, setOpen] = useState(false);
const triggerRef = useRef(null);
const close = useCallback(() => setOpen(false), []);
@@ -47,12 +51,15 @@ function MobileHeaderMenu() {
variant="ghost"
size="icon"
onClick={() => setOpen((v) => !v)}
- className={cn("h-11 w-11", open && "bg-accent")}
- aria-label={t('more')}
+ className={cn("relative h-11 w-11", open && "bg-accent")}
+ aria-label={unread > 0 ? `${t('more')} (${tNotifications('unread_count', { count: unread })})` : t('more')}
aria-haspopup="menu"
aria-expanded={open}
>
+ {unread > 0 && (
+
+ )}
{open && (
+ 0 ? formatBadgeCount(unread) : undefined}
+ onClick={() => { close(); setShowNotifications(true); }}
+ />
)}
+ {showNotifications && setShowNotifications(false)} />}
);
}
diff --git a/components/layout/mv-top-bar.tsx b/components/layout/mv-top-bar.tsx
index d97aefa67..399ec8f2c 100644
--- a/components/layout/mv-top-bar.tsx
+++ b/components/layout/mv-top-bar.tsx
@@ -34,6 +34,7 @@ import { AccountSwitcher } from "@/components/layout/account-switcher";
import { SearchBox, type ContactSearchField } from "@/components/search/search-box";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { usePluginPages, pluginPageHref, isPluginPageActive } from "@/hooks/use-plugin-pages";
+import { NotificationBell } from "@/components/notifications/notification-center";
import type { ContactSuggestion } from "@/lib/search-suggestions";
/** The top bar is not about any one item: plugin clicks carry no context. */
@@ -243,6 +244,7 @@ export function MvTopBar({