feat: let apps add their own commands to the command palette, with account switching and search everything

This commit is contained in:
Linus Rath
2026-10-28 02:00:31 +01:00
parent 6e97ca574d
commit 7e23c7de21
34 changed files with 456 additions and 78 deletions
@@ -88,7 +88,8 @@ describe('command palette', () => {
render(<CommandPaletteHost />);
pressCtrlK();
const labels = screen.getAllByRole('option').map((o) => o.textContent);
// The label, without the key hint after it.
const labels = screen.getAllByRole('option').map((o) => o.querySelector('.flex-1')?.textContent);
expect(labels).toContain('go_to(mail)');
expect(labels).toContain('go_to(settings)');
expect(labels).toContain('compose');
@@ -0,0 +1,125 @@
import React from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const nav = vi.hoisted(() => ({ pathname: '/', push: vi.fn() }));
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, params?: Record<string, string>) =>
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 ? <div>shortcuts help</div> : null),
}));
vi.mock('@/components/global-search/global-search-palette', () => ({
GlobalSearchPalette: () => null,
}));
const { CommandPaletteHost } = await import('../command-palette');
const { useRegisterCommands, commandMatches } = await import('@/lib/command-registry');
const { useAuthStore } = await import('@/stores/auth-store');
const { useAccountStore } = await import('@/stores/account-store');
const { usePolicyStore } = await import('@/stores/policy-store');
const { useGlobalSearchStore } = await import('@/stores/global-search-store');
const { DEFAULT_POLICY } = await import('@/lib/admin/types');
const { Plus } = await import('@/components/icons');
const pressCtrlK = () => act(() => { fireEvent.keyDown(document.body, { key: 'k', code: 'KeyK', ctrlKey: true }); });
const optionIds = () => screen.getAllByRole('option').map((o) => o.getAttribute('data-command-id'));
const search = (text: string) => act(() => { fireEvent.change(screen.getByRole('combobox'), { target: { value: text } }); });
function CalendarApp({ newEvent = () => {}, todayVisible = true }: { newEvent?: () => void; todayVisible?: boolean }) {
useRegisterCommands('Calendar', [
{ id: 'new-event', label: 'New event', icon: Plus, keywords: ['appointment', 'meeting'], shortcut: 'c', run: newEvent },
{ id: 'today', label: 'Go to today', run: () => {}, when: () => todayVisible },
]);
return null;
}
beforeEach(() => {
nav.pathname = '/calendar';
nav.push.mockClear();
useAuthStore.setState({ isAuthenticated: true, client: null, activeAccountId: 'a' } as never);
useAccountStore.setState({ accounts: [] } as never);
usePolicyStore.setState({ policy: { ...DEFAULT_POLICY } });
useGlobalSearchStore.setState({ isOpen: false });
});
describe('commands an app registers', () => {
it('are listed above the global ones while the app is mounted, and go when it unmounts', () => {
const { rerender } = render(<><CalendarApp /><CommandPaletteHost /></>);
pressCtrlK();
const ids = optionIds();
expect(ids.slice(0, 2)).toEqual(['app:Calendar:new-event', 'app:Calendar:today']);
expect(ids.indexOf('app:Calendar:today')).toBeLessThan(ids.indexOf('go-mail'));
expect(screen.getByText('Calendar')).toBeInTheDocument();
act(() => { fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Escape' }); });
rerender(<CommandPaletteHost />);
pressCtrlK();
expect(optionIds()).not.toContain('app:Calendar:new-event');
expect(screen.queryByText('Calendar')).toBeNull();
});
it('are found by label and keywords and run from the palette', () => {
const newEvent = vi.fn();
render(<><CalendarApp newEvent={newEvent} /><CommandPaletteHost /></>);
pressCtrlK();
search('appoint');
expect(optionIds()).toEqual(['app:Calendar:new-event']);
search('new event');
expect(optionIds()).toContain('app:Calendar:new-event');
act(() => { fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter' }); });
expect(newEvent).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('dialog')).toBeNull();
});
it('leave out commands whose `when` is false', () => {
render(<><CalendarApp todayVisible={false} /><CommandPaletteHost /></>);
pressCtrlK();
expect(optionIds()).not.toContain('app:Calendar:today');
expect(optionIds()).toContain('app:Calendar:new-event');
});
it('match every word in any order', () => {
expect(commandMatches('event new', 'New event')).toBe(true);
expect(commandMatches('meeting', 'New event', ['appointment', 'meeting'])).toBe(true);
expect(commandMatches('event task', 'New event')).toBe(false);
});
});
describe('global commands', () => {
it('opens the global search from "Search everything"', () => {
render(<CommandPaletteHost />);
pressCtrlK();
act(() => { fireEvent.click(screen.getByRole('option', { name: 'title' })); });
expect(useGlobalSearchStore.getState().isOpen).toBe(true);
});
it('switches to another account and signs out', () => {
const switchAccount = vi.fn().mockResolvedValue(undefined);
const logout = vi.fn().mockResolvedValue(undefined);
useAuthStore.setState({ switchAccount, logout } as never);
useAccountStore.setState({
accounts: [
{ id: 'a', label: '', email: 'me@example.com', username: 'me', isConnected: true },
{ id: 'b', label: 'Work', email: 'me@work.example', username: 'me', isConnected: true },
],
} as never);
render(<CommandPaletteHost />);
pressCtrlK();
expect(screen.queryByRole('option', { name: /me@example\.com/ })).toBeNull();
act(() => { fireEvent.click(screen.getByRole('option', { name: 'switch_to(Work)' })); });
expect(switchAccount).toHaveBeenCalledWith('b');
pressCtrlK();
act(() => { fireEvent.click(screen.getByRole('option', { name: 'sign_out' })); });
expect(logout).toHaveBeenCalled();
});
});
+81 -22
View File
@@ -7,25 +7,33 @@ import {
Folder,
Users,
Keyboard,
LogOut,
Mail,
Moon,
PenLine,
PenSquare,
Puzzle,
Search,
Settings,
Sun,
UserCircle,
iconForName,
} from "@/components/icons";
import type { AppIcon } from "@/components/icons";
import { usePathname, useRouter } from "@/i18n/navigation";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
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 { usePluginActions } from "@/components/plugins/plugin-actions";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { GlobalSearchPalette } from "@/components/global-search/global-search-palette";
import { runAction } from "@/lib/plugin-sandbox/actions";
import { COMMAND_PALETTE_OPEN_EVENT, parkAppCommand, requestAppCommand, type AppCommand } from "@/lib/app-commands";
import { commandMatches, useRegisteredCommandGroups } from "@/lib/command-registry";
import { shortcutAlternatives } from "@/lib/shortcut-keys";
import { toUnicodeEmail } from "@/lib/idn";
import { isEditableEventTarget } from "@/lib/keyboard";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { cn } from "@/lib/utils";
@@ -36,7 +44,11 @@ interface PaletteCommand {
icon: AppIcon;
/** Shown after the label: the plugin that added the entry. */
source?: string;
group: "navigation" | "actions" | "plugins";
/** The heading of the group the command is listed under. */
group: string;
keywords?: readonly string[];
/** Key hint at the end of the row (devices with a keyboard only). */
shortcut?: string;
run: () => void;
}
@@ -56,7 +68,8 @@ export function CommandPaletteHost() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const [showShortcuts, setShowShortcuts] = useState(false);
const enabled = isAuthenticated && !isProShell(pathname);
const proShell = isProShell(pathname);
const enabled = isAuthenticated && !proShell;
useEffect(() => {
if (!enabled) return;
@@ -84,6 +97,8 @@ export function CommandPaletteHost() {
return (
<>
{open && <CommandPalette onClose={() => setOpen(false)} onShowShortcuts={() => setShowShortcuts(true)} />}
{/* "Search everything" in the standard shell; the Pro shell mounts its own. */}
{enabled && <GlobalSearchPalette proShell={false} />}
<KeyboardShortcutsModal isOpen={showShortcuts} onClose={() => setShowShortcuts(false)} />
</>
);
@@ -92,6 +107,7 @@ export function CommandPaletteHost() {
function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onShowShortcuts: () => void }) {
const t = useTranslations("command_palette");
const tSidebar = useTranslations("sidebar");
const tGlobalSearch = useTranslations("global_search");
const router = useRouter();
const pathname = usePathname();
const listId = useId();
@@ -106,6 +122,9 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
const colorModeLocked = usePolicyStore((s) => s.getColorModePolicy().locked);
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const pluginActions = usePluginActions(surface);
const registeredGroups = useRegisteredCommandGroups();
const accounts = useAccountStore((s) => s.accounts);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const dialogRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true });
@@ -117,38 +136,73 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
parkAppCommand(command);
router.push("/");
};
const list: PaletteCommand[] = [
{ id: "go-mail", group: "navigation", icon: Mail, label: t("go_to", { name: tSidebar("mail") }), run: go("/") },
];
const navigation = t("group_navigation");
const actions = t("group_actions");
const account = t("group_account");
// The current app's own commands come first (useRegisterCommands).
const list: PaletteCommand[] = registeredGroups.flatMap((group) =>
group.commands.map((command) => ({
id: `app:${group.label}:${command.id}`,
group: group.label,
icon: command.icon ?? Puzzle,
label: command.label,
keywords: command.keywords,
shortcut: command.shortcut,
run: command.run,
})),
);
list.push({ 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: CalendarEvent, label: t("go_to", { name: tSidebar("calendar") }), run: go("/calendar") });
list.push({ id: "go-calendar", group: navigation, icon: CalendarEvent, label: t("go_to", { name: tSidebar("calendar") }), run: go("/calendar") });
}
if ((client?.supportsContacts?.() ?? false) && isFeatureEnabled("contactsEnabled")) {
list.push({ id: "go-contacts", group: "navigation", icon: Users, label: t("go_to", { name: tSidebar("contacts") }), run: go("/contacts") });
list.push({ id: "go-contacts", group: navigation, icon: Users, label: t("go_to", { name: tSidebar("contacts") }), run: go("/contacts") });
}
if ((client?.supportsFiles?.() ?? false) && isFeatureEnabled("filesEnabled")) {
list.push({ id: "go-files", group: "navigation", icon: Folder, label: t("go_to", { name: tSidebar("files") }), run: go("/files") });
list.push({ id: "go-files", group: navigation, icon: Folder, 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: "go-settings", group: navigation, icon: Settings, label: t("go_to", { name: tSidebar("settings") }), run: go("/settings") },
{ id: "compose", group: actions, icon: PenSquare, label: t("compose"), shortcut: "c", run: onMail("compose") },
{ id: "search", group: actions, icon: Search, label: t("search"), shortcut: "/", run: onMail("search") },
{
id: "search-everything",
group: actions,
icon: Search,
label: tGlobalSearch("title"),
keywords: [tSidebar("contacts"), tSidebar("calendar"), tSidebar("files")],
run: () => useGlobalSearchStore.getState().openPalette(),
},
);
// The theme policy can pin the color mode (#1280).
if (!colorModeLocked) {
list.push({
id: "theme",
group: "actions",
group: actions,
icon: resolvedTheme === "dark" ? Sun : Moon,
label: resolvedTheme === "dark" ? t("theme_light") : t("theme_dark"),
run: () => useThemeStore.getState().setTheme(resolvedTheme === "dark" ? "light" : "dark"),
});
}
list.push({ id: "shortcuts", group: "actions", icon: Keyboard, label: t("shortcuts"), run: onShowShortcuts });
list.push({ id: "shortcuts", group: actions, icon: Keyboard, label: t("shortcuts"), shortcut: "?", run: onShowShortcuts });
// Account: switch to each other signed-in account, sign out.
for (const entry of accounts) {
if (entry.id === activeAccountId) continue;
const name = entry.label || toUnicodeEmail(entry.email || entry.username);
list.push({
id: `account:${entry.id}`,
group: account,
icon: UserCircle,
label: t("switch_to", { account: name }),
keywords: [tSidebar("switch_account"), entry.email],
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(); } });
for (const action of pluginActions) {
list.push({
id: `plugin:${action.pluginId}:${action.id}`,
group: "plugins",
group: t("group_plugins"),
icon: iconForName(action.icon) ?? Puzzle,
label: action.label,
source: action.pluginName,
@@ -156,11 +210,11 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
});
}
return list;
}, [client, supportsCalendar, isFeatureEnabled, colorModeLocked, resolvedTheme, pluginActions, router, t, tSidebar, onShowShortcuts]);
}, [client, supportsCalendar, isFeatureEnabled, colorModeLocked, resolvedTheme, pluginActions, registeredGroups, accounts, activeAccountId, router, t, tSidebar, tGlobalSearch, onShowShortcuts]);
const needle = query.trim().toLowerCase();
const needle = query.trim();
const visible = needle
? commands.filter((c) => c.label.toLowerCase().includes(needle) || (c.source?.toLowerCase().includes(needle) ?? false))
? commands.filter((c) => commandMatches(needle, c.label, [...(c.keywords ?? []), ...(c.source ? [c.source] : [])]))
: commands;
const activeIndex = visible.length === 0 ? -1 : Math.min(active, visible.length - 1);
@@ -203,9 +257,6 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
}
};
const groupLabel = (group: PaletteCommand["group"]) =>
group === "navigation" ? t("group_navigation") : group === "actions" ? t("group_actions") : t("group_plugins");
return (
<div
className="fixed inset-0 z-[60] flex items-start justify-center bg-black/50 p-4 pt-[12vh] max-sm:p-0 max-sm:pt-0"
@@ -259,7 +310,7 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
<li key={command.id} role="presentation">
{showGroup && (
<div aria-hidden="true" className="px-3 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{groupLabel(command.group)}
{command.group}
</div>
)}
<div
@@ -281,6 +332,14 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
{command.source && (
<span className="flex-shrink-0 truncate max-w-[40%] text-xs text-muted-foreground">{command.source}</span>
)}
{command.shortcut && (
// Key hints only where there is a keyboard (R8).
<span className="flex-shrink-0 flex items-center gap-1 pointer-coarse:hidden" aria-hidden="true">
{shortcutAlternatives(command.shortcut).map((key) => (
<kbd key={key} className="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">{key}</kbd>
))}
</span>
)}
</div>
</li>
);
+101
View File
@@ -0,0 +1,101 @@
'use client';
// Commands an app adds to the command palette (Ctrl/⌘+K) while it is
// mounted. The palette lists the registered groups above its own global
// commands and searches their labels and keywords. Registering happens in
// the app's component, so the commands come and go with the app.
import { useEffect, useRef, useSyncExternalStore } from 'react';
import type { AppIcon } from '@/components/icons';
export interface RegisteredCommand {
/** Unique within the group. */
id: string;
/** Translated, sentence case: "New event", "Go to today". */
label: string;
icon?: AppIcon;
/** More words the search matches, e.g. ["calendar", "appointment"]. Translated too. */
keywords?: readonly string[];
/** A key hint shown at the end of the row on devices with a keyboard, in the shortcut notation ("c", "Mod + Enter"). */
shortcut?: string;
run: () => void;
/** Listed only while this returns true, checked each time the palette renders. */
when?: () => boolean;
}
export interface CommandGroup {
/** The group heading, translated ("Calendar"). Also identifies the group. */
label: string;
commands: readonly RegisteredCommand[];
}
interface Registration {
token: object;
read: () => CommandGroup;
}
let registrations: readonly Registration[] = [];
const listeners = new Set<() => void>();
function emit() {
for (const listener of listeners) listener();
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
const getSnapshot = () => registrations;
const getServerSnapshot = (): readonly Registration[] => [];
/** Register a group; returns the function that removes it again. */
export function registerCommands(read: () => CommandGroup): () => void {
const entry: Registration = { token: {}, read };
registrations = [...registrations, entry];
emit();
return () => {
registrations = registrations.filter((r) => r !== entry);
emit();
};
}
/**
* Add a group of commands to the palette while this component is mounted.
* The group and commands are read when the palette renders, so they can
* change every render without registering again.
*
* useRegisterCommands(t("sidebar.calendar"), [
* { id: "new-event", label: t("calendar.new_event"), icon: Plus, keywords: [t("calendar.event")], shortcut: "c", run: openNewEvent },
* { id: "today", label: t("calendar.today"), icon: CalendarEvent, run: goToToday, when: () => view !== "agenda" },
* ]);
*/
export function useRegisterCommands(group: string, commands: readonly RegisteredCommand[], enabled = true): void {
const latest = useRef<CommandGroup>({ label: group, commands });
useEffect(() => {
latest.current = { label: group, commands };
});
useEffect(() => {
if (!enabled) return;
return registerCommands(() => latest.current);
}, [enabled]);
}
/** The groups registered right now, in the order they were registered, with `when` applied. */
export function useRegisteredCommandGroups(): CommandGroup[] {
const regs = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
return regs
.map((r) => {
const group = r.read();
return { label: group.label, commands: group.commands.filter((c) => !c.when || c.when()) };
})
.filter((g) => g.commands.length > 0);
}
/** Whether a command matches a search: every word of it in the label or the keywords. */
export function commandMatches(needle: string, label: string, keywords: readonly string[] = []): boolean {
const words = needle.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (words.length === 0) return true;
const haystack = [label, ...keywords].join(' ').toLowerCase();
return words.every((w) => haystack.includes(w));
}
+34
View File
@@ -0,0 +1,34 @@
// One notation for key combinations in the help modal and in hints: "Mod"
// (and the older "Ctrl/⌘", "Ctrl/Cmd" forms) is ⌘ on Apple devices and Ctrl
// everywhere else; Alt is ⌥ on Apple devices. Entries are written with
// "Mod + K", "Shift + Mod + Enter", alternatives separated by " / ".
export function isApplePlatform(): boolean {
if (typeof navigator === 'undefined') return false;
const nav = navigator as Navigator & { userAgentData?: { platform?: string } };
const platform = nav.userAgentData?.platform || nav.platform || nav.userAgent || '';
return /mac|iphone|ipad|ipod/i.test(platform);
}
const MOD = /^(mod|ctrl\/⌘|ctrl\/cmd|cmd\/ctrl|⌘\/ctrl)$/i;
/** One key or combination ("Mod + Enter") in the notation of this platform. */
export function formatShortcutCombo(combo: string, apple = isApplePlatform()): string {
// Keeps the spacing it was written with ("Ctrl+Shift+Y" from a plugin).
const separator = /\s\+\s/.test(combo) ? " + " : "+";
return combo
.split(/\s*\+\s*/)
.map((part) => {
if (MOD.test(part)) return apple ? '⌘' : 'Ctrl';
if (/^ctrl$/i.test(part)) return apple ? '⌘' : 'Ctrl';
if (/^(cmd|⌘)$/i.test(part)) return apple ? '⌘' : 'Ctrl';
if (/^alt$/i.test(part)) return apple ? '⌥' : 'Alt';
return part;
})
.join(separator);
}
/** The alternatives of a shortcut ("j / ↓"), each in the notation of this platform. */
export function shortcutAlternatives(key: string, apple = isApplePlatform()): string[] {
return key.split(' / ').map((combo) => formatShortcutCombo(combo, apple));
}
+4 -2
View File
@@ -4567,14 +4567,16 @@
"placeholder": "اكتب أمرًا",
"no_results": "لا توجد أوامر مطابقة",
"go_to": "الانتقال إلى {name}",
"compose": "رسالة جديدة",
"compose": "إنشاء رسالة",
"search": "البحث في البريد",
"theme_dark": "التبديل إلى المظهر الداكن",
"theme_light": "التبديل إلى المظهر الفاتح",
"shortcuts": "عرض اختصارات لوحة المفاتيح",
"group_navigation": "الانتقال إلى",
"group_actions": "إجراءات",
"group_plugins": "الإضافات"
"group_plugins": "الإضافات",
"group_account": "الحساب",
"switch_to": "التبديل إلى {account}"
},
"notification_center": {
"title": "الإشعارات",
+4 -2
View File
@@ -4567,14 +4567,16 @@
"placeholder": "Escriviu una ordre",
"no_results": "Cap ordre coincident",
"go_to": "Ves a {name}",
"compose": "Missatge nou",
"compose": "Redacta",
"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"
"group_plugins": "Connectors",
"group_account": "Compte",
"switch_to": "Canvia a {account}"
},
"notification_center": {
"title": "Notificacions",
+4 -2
View File
@@ -4567,14 +4567,16 @@
"placeholder": "Zadejte příkaz",
"no_results": "Žádné odpovídající příkazy",
"go_to": "Přejít na {name}",
"compose": "Nová zpráva",
"compose": "Napsat",
"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"
"group_plugins": "Pluginy",
"group_account": "Účet",
"switch_to": "Přepnout na {account}"
},
"notification_center": {
"title": "Oznámení",
+3 -1
View File
@@ -4574,7 +4574,9 @@
"shortcuts": "Vis tastaturgenveje",
"group_navigation": "Gå til",
"group_actions": "Handlinger",
"group_plugins": "Plugins"
"group_plugins": "Plugins",
"group_account": "Konto",
"switch_to": "Skift til {account}"
},
"notification_center": {
"title": "Notifikationer",
+4 -2
View File
@@ -4567,14 +4567,16 @@
"placeholder": "Befehl eingeben",
"no_results": "Keine passenden Befehle",
"go_to": "Zu {name} wechseln",
"compose": "Neue Nachricht",
"compose": "Verfassen",
"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"
"group_plugins": "Plugins",
"group_account": "Konto",
"switch_to": "Zu {account} wechseln"
},
"notification_center": {
"title": "Benachrichtigungen",
+4 -2
View File
@@ -4567,14 +4567,16 @@
"placeholder": "Type a command",
"no_results": "No matching commands",
"go_to": "Go to {name}",
"compose": "New message",
"compose": "Compose",
"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"
"group_plugins": "Plugins",
"group_account": "Account",
"switch_to": "Switch to {account}"
},
"notification_center": {
"title": "Notifications",
+4 -2
View File
@@ -4567,14 +4567,16 @@
"placeholder": "Escribe un comando",
"no_results": "No hay comandos que coincidan",
"go_to": "Ir a {name}",
"compose": "Mensaje nuevo",
"compose": "Redactar",
"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"
"group_plugins": "Complementos",
"group_account": "Cuenta",
"switch_to": "Cambiar a {account}"
},
"notification_center": {
"title": "Notificaciones",
+4 -2
View File
@@ -4567,14 +4567,16 @@
"placeholder": "Trüki käsk",
"no_results": "Sobivaid käske pole",
"go_to": "Mine: {name}",
"compose": "Uus kiri",
"compose": "Koosta",
"search": "Otsi kirju",
"theme_dark": "Lülitu tumedale teemale",
"theme_light": "Lülitu heledale teemale",
"shortcuts": "Näita kiirklahve",
"group_navigation": "Mine",
"group_actions": "Toimingud",
"group_plugins": "Pistikprogrammid"
"group_plugins": "Pistikprogrammid",
"group_account": "Konto",
"switch_to": "Lülitu kontole {account}"
},
"notification_center": {
"title": "Märguanded",
+4 -2
View File
@@ -4567,14 +4567,16 @@
"placeholder": "یک فرمان بنویسید",
"no_results": "فرمان منطبقی پیدا نشد",
"go_to": "رفتن به {name}",
"compose": "پیام جدید",
"compose": "ایمیل جدید",
"search": "جستجو در ایمیل",
"theme_dark": "رفتن به پوسته تیره",
"theme_light": "رفتن به پوسته روشن",
"shortcuts": "نمایش میانبرهای صفحه کلید",
"group_navigation": "رفتن به",
"group_actions": "کارها",
"group_plugins": "افزونه‌ها"
"group_plugins": "افزونه‌ها",
"group_account": "حساب",
"switch_to": "رفتن به {account}"
},
"notification_center": {
"title": "اعلان‌ها",
+4 -2
View File
@@ -4567,14 +4567,16 @@
"placeholder": "Tapez une commande",
"no_results": "Aucune commande correspondante",
"go_to": "Aller à {name}",
"compose": "Nouveau message",
"compose": "Composer",
"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"
"group_plugins": "Plugins",
"group_account": "Compte",
"switch_to": "Passer à {account}"
},
"notification_center": {
"title": "Notifications",
+4 -2
View File
@@ -4568,14 +4568,16 @@
"placeholder": "הקלידו פקודה",
"no_results": "אין פקודות תואמות",
"go_to": "מעבר אל {name}",
"compose": "הודעה חדשה",
"compose": "כתיבה",
"search": "חיפוש בדואר",
"theme_dark": "מעבר לערכת נושא כהה",
"theme_light": "מעבר לערכת נושא בהירה",
"shortcuts": "הצגת קיצורי מקשים",
"group_navigation": "מעבר אל",
"group_actions": "פעולות",
"group_plugins": "תוספים"
"group_plugins": "תוספים",
"group_account": "חשבון",
"switch_to": "מעבר אל {account}"
},
"notification_center": {
"title": "התראות",
+3 -1
View File
@@ -4574,7 +4574,9 @@
"shortcuts": "Billentyűparancsok megjelenítése",
"group_navigation": "Ugrás ide",
"group_actions": "Műveletek",
"group_plugins": "Bővítmények"
"group_plugins": "Bővítmények",
"group_account": "Fiók",
"switch_to": "Váltás erre: {account}"
},
"notification_center": {
"title": "Értesítések",
+4 -2
View File
@@ -4567,14 +4567,16 @@
"placeholder": "Digita un comando",
"no_results": "Nessun comando corrispondente",
"go_to": "Vai a {name}",
"compose": "Nuovo messaggio",
"compose": "Scrivi",
"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"
"group_plugins": "Plugin",
"group_account": "Account",
"switch_to": "Passa a {account}"
},
"notification_center": {
"title": "Notifiche",
+4 -2
View File
@@ -4567,14 +4567,16 @@
"placeholder": "コマンドを入力",
"no_results": "一致するコマンドはありません",
"go_to": "{name}に移動",
"compose": "新規メッセージ",
"compose": "作成",
"search": "メールを検索",
"theme_dark": "ダークテーマに切り替え",
"theme_light": "ライトテーマに切り替え",
"shortcuts": "キーボードショートカットを表示",
"group_navigation": "移動",
"group_actions": "操作",
"group_plugins": "プラグイン"
"group_plugins": "プラグイン",
"group_account": "アカウント",
"switch_to": "{account} に切り替え"
},
"notification_center": {
"title": "通知",
+4 -2
View File
@@ -4567,14 +4567,16 @@
"placeholder": "명령을 입력하세요",
"no_results": "일치하는 명령이 없습니다",
"go_to": "{name}(으)로 이동",
"compose": "새 메일",
"compose": "메일 쓰기",
"search": "메일 검색",
"theme_dark": "어두운 테마로 전환",
"theme_light": "밝은 테마로 전환",
"shortcuts": "단축키 보기",
"group_navigation": "이동",
"group_actions": "작업",
"group_plugins": "플러그인"
"group_plugins": "플러그인",
"group_account": "계정",
"switch_to": "{account}(으)로 전환"
},
"notification_center": {
"title": "알림",
+4 -2
View File
@@ -4567,14 +4567,16 @@
"placeholder": "Ierakstiet komandu",
"no_results": "Nav atbilstošu komandu",
"go_to": "Pāriet uz: {name}",
"compose": "Jauna vēstule",
"compose": "Rakstīt",
"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"
"group_plugins": "Spraudņi",
"group_account": "Konts",
"switch_to": "Pārslēgties uz {account}"
},
"notification_center": {
"title": "Paziņojumi",
+4 -2
View File
@@ -4567,14 +4567,16 @@
"placeholder": "Команд бичнэ үү",
"no_results": "Тохирох команд алга",
"go_to": "{name} руу очих",
"compose": "Шинэ захидал",
"compose": "Бичилэх",
"search": "Мэйлээс хайх",
"theme_dark": "Бараан загвар руу шилжих",
"theme_light": "Цайвар загвар руу шилжих",
"shortcuts": "Гарын товчлолыг харуулах",
"group_navigation": "Очих",
"group_actions": "Үйлдлүүд",
"group_plugins": "Залгаасууд"
"group_plugins": "Залгаасууд",
"group_account": "Бүртгэл",
"switch_to": "{account} руу шилжих"
},
"notification_center": {
"title": "Мэдэгдэл",
+4 -2
View File
@@ -4567,14 +4567,16 @@
"placeholder": "Skriv en kommando",
"no_results": "Ingen samsvarende kommandoer",
"go_to": "Gå til {name}",
"compose": "Ny melding",
"compose": "Skriv",
"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"
"group_plugins": "Tillegg",
"group_account": "Konto",
"switch_to": "Bytt til {account}"
},
"notification_center": {
"title": "Varsler",
+3 -1
View File
@@ -4574,7 +4574,9 @@
"shortcuts": "Sneltoetsen tonen",
"group_navigation": "Naar",
"group_actions": "Acties",
"group_plugins": "Plug-ins"
"group_plugins": "Plug-ins",
"group_account": "Account",
"switch_to": "Overschakelen naar {account}"
},
"notification_center": {
"title": "Meldingen",
+4 -2
View File
@@ -4567,14 +4567,16 @@
"placeholder": "Wpisz polecenie",
"no_results": "Brak pasujących poleceń",
"go_to": "Przejdź do: {name}",
"compose": "Nowa wiadomość",
"compose": "Napisz",
"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"
"group_plugins": "Wtyczki",
"group_account": "Konto",
"switch_to": "Przełącz na {account}"
},
"notification_center": {
"title": "Powiadomienia",
+4 -2
View File
@@ -4567,14 +4567,16 @@
"placeholder": "Digite um comando",
"no_results": "Nenhum comando correspondente",
"go_to": "Ir para {name}",
"compose": "Nova mensagem",
"compose": "Escrever",
"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"
"group_plugins": "Plugins",
"group_account": "Conta",
"switch_to": "Mudar para {account}"
},
"notification_center": {
"title": "Notificações",
+4 -2
View File
@@ -4567,14 +4567,16 @@
"placeholder": "Tastați o comandă",
"no_results": "Nicio comandă potrivită",
"go_to": "Mergi la {name}",
"compose": "Mesaj nou",
"compose": "Compune",
"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"
"group_plugins": "Pluginuri",
"group_account": "Cont",
"switch_to": "Comută la {account}"
},
"notification_center": {
"title": "Notificări",
+4 -2
View File
@@ -4567,14 +4567,16 @@
"placeholder": "Введите команду",
"no_results": "Нет подходящих команд",
"go_to": "Перейти: {name}",
"compose": "Новое письмо",
"compose": "Создать",
"search": "Искать в почте",
"theme_dark": "Включить тёмную тему",
"theme_light": "Включить светлую тему",
"shortcuts": "Показать сочетания клавиш",
"group_navigation": "Перейти",
"group_actions": "Действия",
"group_plugins": "Плагины"
"group_plugins": "Плагины",
"group_account": "Аккаунт",
"switch_to": "Переключиться на {account}"
},
"notification_center": {
"title": "Уведомления",
+4 -2
View File
@@ -4567,14 +4567,16 @@
"placeholder": "Zadajte príkaz",
"no_results": "Žiadne zodpovedajúce príkazy",
"go_to": "Prejsť na {name}",
"compose": "Nová správa",
"compose": "Napísať",
"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"
"group_plugins": "Doplnky",
"group_account": "Účet",
"switch_to": "Prepnúť na {account}"
},
"notification_center": {
"title": "Oznámenia",
+4 -2
View File
@@ -62,14 +62,16 @@
"placeholder": "พิมพ์คำสั่งหรือค้นหา...",
"no_results": "ไม่พบคำสั่งที่ตรงกัน",
"go_to": "ไปยัง {name}",
"compose": "เขียนจดหมายใหม่",
"compose": "เขียนจดหมาย",
"search": "ค้นหาจดหมาย",
"theme_dark": "เปลี่ยนเป็นธีมมืด",
"theme_light": "เปลี่ยนเป็นธีมสว่าง",
"shortcuts": "แสดงแป้นพิมพ์ลัด",
"group_navigation": "ไปยัง",
"group_actions": "การดำเนินการ",
"group_plugins": "ปลั๊กอิน"
"group_plugins": "ปลั๊กอิน",
"group_account": "บัญชี",
"switch_to": "สลับไปที่ {account}"
},
"notification_center": {
"title": "การแจ้งเตือน",
+4 -2
View File
@@ -4567,14 +4567,16 @@
"placeholder": "Bir komut yazın",
"no_results": "Eşleşen komut yok",
"go_to": "{name} bölümüne git",
"compose": "Yeni ileti",
"compose": "Yeni E-posta",
"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"
"group_plugins": "Eklentiler",
"group_account": "Hesap",
"switch_to": "{account} hesabına geç"
},
"notification_center": {
"title": "Bildirimler",
+4 -2
View File
@@ -4567,14 +4567,16 @@
"placeholder": "Введіть команду",
"no_results": "Немає відповідних команд",
"go_to": "Перейти: {name}",
"compose": "Новий лист",
"compose": "Скласти",
"search": "Шукати в пошті",
"theme_dark": "Увімкнути темну тему",
"theme_light": "Увімкнути світлу тему",
"shortcuts": "Показати комбінації клавіш",
"group_navigation": "Перейти",
"group_actions": "Дії",
"group_plugins": "Плагіни"
"group_plugins": "Плагіни",
"group_account": "Обліковий запис",
"switch_to": "Перейти до {account}"
},
"notification_center": {
"title": "Сповіщення",
+4 -2
View File
@@ -4567,14 +4567,16 @@
"placeholder": "輸入命令",
"no_results": "沒有相符的命令",
"go_to": "前往{name}",
"compose": "新郵件",
"compose": "撰寫郵件",
"search": "搜尋郵件",
"theme_dark": "切換為深色主題",
"theme_light": "切換為淺色主題",
"shortcuts": "顯示鍵盤快速鍵",
"group_navigation": "前往",
"group_actions": "動作",
"group_plugins": "外掛程式"
"group_plugins": "外掛程式",
"group_account": "帳戶",
"switch_to": "切換到 {account}"
},
"notification_center": {
"title": "通知",
+4 -2
View File
@@ -4567,14 +4567,16 @@
"placeholder": "输入命令",
"no_results": "没有匹配的命令",
"go_to": "前往{name}",
"compose": "新邮件",
"compose": "撰写邮件",
"search": "搜索邮件",
"theme_dark": "切换到深色主题",
"theme_light": "切换到浅色主题",
"shortcuts": "显示键盘快捷键",
"group_navigation": "前往",
"group_actions": "操作",
"group_plugins": "插件"
"group_plugins": "插件",
"group_account": "账户",
"switch_to": "切换到 {account}"
},
"notification_center": {
"title": "通知",