Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ jobs:
- uses: actions/checkout@v6
with:
# Needed so commitlint can walk the PR's commit range.
fetch-depth: ${{ github.event_name == 'pull_request' && 0 || 1 }}
# Quoted because `&& 0 || 1` short-circuits past the falsy 0 and always returns 1.
fetch-depth: ${{ github.event_name == 'pull_request' && '0' || '1' }}

- uses: pnpm/action-setup@v6

Expand Down
1 change: 1 addition & 0 deletions .github/workflows/pr-title.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:

permissions:
pull-requests: read
statuses: write
Comment thread
Medformatik marked this conversation as resolved.
Outdated

jobs:
validate:
Expand Down
14 changes: 13 additions & 1 deletion apps/web/src/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,21 @@ import CssBaseline from "@mui/material/CssBaseline";
import { createTheme, ThemeProvider } from "@mui/material/styles";
import { configureStorage, registerBuiltinIdSchemeViews } from "@openmapx/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { ImpersonationBanner } from "../components/admin/ImpersonationBanner";
import { localStorageAdapter } from "../lib/storage";
import { IntegrationProvider } from "../providers/IntegrationProvider";
import { KeypairSessionGuard } from "../providers/KeypairSessionGuard";

const GlobalKeybindings = dynamic(
() =>
import("../components/command-palette/GlobalKeybindings").then((m) => ({
default: m.GlobalKeybindings,
})),
{ ssr: false },
);

configureStorage(localStorageAdapter);
registerBuiltinIdSchemeViews();

Expand Down Expand Up @@ -98,7 +107,10 @@ export function Providers({ children }: { children: React.ReactNode }) {
<CssBaseline />
<ImpersonationBanner />
<KeypairSessionGuard />
<IntegrationProvider>{children}</IntegrationProvider>
<IntegrationProvider>
{children}
<GlobalKeybindings />
</IntegrationProvider>
</ThemeProvider>
</QueryClientProvider>
);
Expand Down
211 changes: 211 additions & 0 deletions apps/web/src/components/command-palette/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
"use client";

import Dialog from "@mui/material/Dialog";
import { useTheme } from "@mui/material/styles";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import {
type Command,
getPlatform,
SCORE_CUTOFF,
scoreCommand,
useCommandPaletteStore,
useSearchStore,
} from "@openmapx/core";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CommandPaletteFooter } from "./CommandPaletteFooter";
import { CommandPaletteInput } from "./CommandPaletteInput";
import { CommandPaletteList, getDefaultCommandPaletteRows } from "./CommandPaletteList";
import { COMMAND_PALETTE_LISTBOX_ID, SEARCH_INPUT_ID } from "./constants";
import { useCommandSources } from "./useCommandSources";

interface Props {
/** Called when the user runs the "Show keyboard shortcuts" command. */
onOpenShortcuts: () => void;
}

const SEARCH_FALLBACK_ID = "search.fallback";

export function CommandPalette({ onOpenShortcuts }: Props) {
const theme = useTheme();
const isXs = useMediaQuery(theme.breakpoints.down("sm"));
const t = useTranslations("commandPalette");

const isOpen = useCommandPaletteStore((s) => s.isOpen);
const close = useCommandPaletteStore((s) => s.close);
const query = useCommandPaletteStore((s) => s.query);
const setQuery = useCommandPaletteStore((s) => s.setQuery);

const setSearchQuery = useSearchStore((s) => s.setQuery);

const inputRef = useRef<HTMLInputElement | null>(null);
const [highlight, setHighlight] = useState(0);

const handleQueryChange = useCallback(
(nextQuery: string) => {
setQuery(nextQuery);
setHighlight(0);
},
[setQuery],
);

// Hand-off to SearchBar — runs the user's query through the regular search flow.
const handleSearchOnMap = useCallback(() => {
const q = query.trim();
close();
if (!q) return;
requestAnimationFrame(() => {
setSearchQuery(q);
const el = document.getElementById(SEARCH_INPUT_ID) as HTMLInputElement | null;
el?.focus();
});
}, [close, query, setSearchQuery]);

const commands = useCommandSources({ openShortcutsDialog: onOpenShortcuts });

Comment thread
Medformatik marked this conversation as resolved.
Outdated
// Ranked filtered list when query is present, otherwise the raw command list.
// When filtering, the synthetic "Search '<q>' on map" row is always appended so
// it can be navigated by ↑↓ alongside real matches.
const ranked = useMemo<Command[] | null>(() => {
const q = query.trim();
if (!q) return null;
const matches = commands
.map((c) => ({ c, s: scoreCommand(q, c) }))
.filter((x) => x.s >= SCORE_CUTOFF)
.sort((a, b) => b.s - a.s)
.map((x) => x.c);
const fallback: Command = {
id: SEARCH_FALLBACK_ID,
group: "search",
label: t("searchOnMap", { query: q }),
iconKey: "search",
run: handleSearchOnMap,
};
return [...matches, fallback];
}, [commands, query, t, handleSearchOnMap]);

const visible = useMemo(
() => ranked ?? getDefaultCommandPaletteRows(commands),
[ranked, commands],
);
const noRealMatches = ranked !== null && ranked.length === 1; // only the fallback

// Reset state on open/close
useEffect(() => {
if (isOpen) {
setHighlight(0);
// Auto-select existing text on re-open
requestAnimationFrame(() => inputRef.current?.select());
}
}, [isOpen]);

// Keep the highlighted row scrolled into view as the user arrow-keys past the
// visible area.
useEffect(() => {
const cmd = visible[highlight];
if (!cmd) return;
const el = document.getElementById(`command-row-${cmd.id}`);
el?.scrollIntoView({ block: "nearest" });
}, [highlight, visible]);

const runCommand = useCallback(
(cmd: Command, event?: { metaKey?: boolean; ctrlKey?: boolean }) => {
// Platform-aware "keep open" modifier so Ctrl+Enter doesn't trigger
// it on macOS (where the user expects ⌘+Enter only).
const isMac = getPlatform() === "mac";
const keepOpen = isMac ? !!event?.metaKey : !!event?.ctrlKey;
try {
const result = cmd.run();
if (!keepOpen && result !== false) close();
} catch (e) {
Comment thread
Medformatik marked this conversation as resolved.
console.error(`[command-palette] '${cmd.id}' failed:`, e);
return;
}
},
[close],
);

const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlight((h) => (visible.length === 0 ? 0 : (h + 1) % visible.length));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setHighlight((h) => (visible.length === 0 ? 0 : (h - 1 + visible.length) % visible.length));
return;
}
if (e.key === "Enter") {
e.preventDefault();
const cmd = visible[highlight];
if (cmd) {
runCommand(cmd, { metaKey: e.metaKey, ctrlKey: e.ctrlKey });
}
return;
}
// Escape is handled by the global keybindings listener.
},
[visible, highlight, runCommand],
);

const selected = visible[highlight] ?? null;
const selectedDomId = selected ? `command-row-${selected.id}` : null;

return (
<Dialog
open={isOpen}
onClose={close}
fullScreen={isXs}
maxWidth="sm"
fullWidth
slotProps={{
paper: {
sx: {
position: (isXs ? undefined : "absolute") as "absolute" | undefined,
top: isXs ? undefined : 80,
m: isXs ? 0 : 2,
borderRadius: isXs ? 0 : 2,
maxHeight: isXs ? "100dvh" : "70dvh",
display: "flex",
flexDirection: "column",
overflow: "hidden",
},
},
backdrop: {
sx: { backgroundColor: "rgba(0,0,0,0.32)" },
},
}}
>
<CommandPaletteInput
ref={inputRef}
query={query}
onQueryChange={handleQueryChange}
onClose={close}
onKeyDown={handleKeyDown}
activeDescendantId={selectedDomId}
/>
<div style={{ overflowY: "auto", flex: 1 }}>
{noRealMatches && (
<Typography sx={{ px: 2, py: 1.5, color: "text.secondary", fontSize: 14 }}>
{t("noResults")}
</Typography>
)}
<CommandPaletteList
commands={commands}
rankedOverride={ranked}
query={query}
selectedId={selected?.id ?? null}
listboxId={COMMAND_PALETTE_LISTBOX_ID}
onRun={(cmd, e) => {
const evt = e as unknown as { metaKey?: boolean; ctrlKey?: boolean };
runCommand(cmd, evt);
}}
/>
</div>
<CommandPaletteFooter />
</Dialog>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"use client";

import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import { getPlatform } from "@openmapx/core";
import { useTranslations } from "next-intl";

const KBD_SX = {
fontFamily: "monospace",
fontSize: 11,
px: 0.5,
py: 0.1,
border: 1,
borderColor: "divider",
borderRadius: 0.5,
color: "text.secondary",
} as const;

export function CommandPaletteFooter() {
const t = useTranslations("commandPalette");
const theme = useTheme();
const isXs = useMediaQuery(theme.breakpoints.down("sm"));
if (isXs) return null;

const modKey = getPlatform() === "mac" ? "⌘" : "Ctrl";

return (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
px: 2,
py: 0.75,
borderTop: 1,
borderColor: "divider",
bgcolor: "background.default",
}}
>
<FooterHint kbd={["↑", "↓"]} label={t("footerNavigate")} />
<FooterHint kbd={["↵"]} label={t("footerSelect")} />
<FooterHint kbd={[modKey, "↵"]} label={t("footerSelectAndKeep")} />
<FooterHint kbd={["esc"]} label={t("footerClose")} />
Comment thread
Medformatik marked this conversation as resolved.
</Box>
);
}

function FooterHint({ kbd, label }: { kbd: string[]; label: string }) {
return (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
{kbd.map((k) => (
<Typography key={k} component="kbd" sx={KBD_SX}>
{k}
</Typography>
))}
<Typography variant="caption" color="text.secondary">
{label}
</Typography>
</Box>
);
}
62 changes: 62 additions & 0 deletions apps/web/src/components/command-palette/CommandPaletteInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"use client";

import CloseIcon from "@mui/icons-material/Close";
import SearchIcon from "@mui/icons-material/Search";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import InputBase from "@mui/material/InputBase";
import { useTranslations } from "next-intl";
import { forwardRef, type KeyboardEvent } from "react";
import { COMMAND_PALETTE_LISTBOX_ID } from "./constants";

interface Props {
query: string;
onQueryChange: (q: string) => void;
onClose: () => void;
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => void;
activeDescendantId: string | null;
}

export const CommandPaletteInput = forwardRef<HTMLInputElement, Props>(function CommandPaletteInput(
{ query, onQueryChange, onClose, onKeyDown, activeDescendantId },
ref,
) {
const t = useTranslations("commandPalette");
const tCommon = useTranslations("common");

return (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
px: 2,
py: 1.25,
borderBottom: 1,
borderColor: "divider",
}}
>
<SearchIcon color="action" fontSize="small" />
<InputBase
inputRef={ref}
value={query}
onChange={(e) => onQueryChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder={t("placeholder")}
autoFocus
fullWidth
inputProps={{
role: "combobox",
"aria-expanded": true,
"aria-controls": COMMAND_PALETTE_LISTBOX_ID,
"aria-activedescendant": activeDescendantId ?? undefined,
"aria-autocomplete": "list",
}}
Comment thread
Medformatik marked this conversation as resolved.
sx={{ fontSize: 16 }}
/>
<IconButton onClick={onClose} aria-label={tCommon("close")} size="small">
<CloseIcon fontSize="small" />
</IconButton>
</Box>
);
});
Loading
Loading