mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: let apps add their own commands to the command palette, with account switching and search everything
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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": "الإشعارات",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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í",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "اعلانها",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "התראות",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "通知",
|
||||
|
||||
@@ -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": "알림",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Мэдэгдэл",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Уведомления",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "การแจ้งเตือน",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Сповіщення",
|
||||
|
||||
@@ -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": "通知",
|
||||
|
||||
@@ -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": "通知",
|
||||
|
||||
Reference in New Issue
Block a user