History

feat(react): add global command palette (⌘K) (#168)

* feat(react): add global command palette (⌘K) Introduces `<CommandPalette>`, a shared keyboard-driven navigator that lists sources grouped by plugin. Wired into both the cloud and local app shells so users can jump between sources without reaching for the sidebar. The palette is plugin-driven: each shell passes the set of source plugins it has loaded, and the component renders the entries grouped by plugin kind. * fix(react/command-palette): drop missing SourceFavicon and url field The sources/list API response has no url field, and source-favicon was never added to this branch. Typecheck was failing on both. * use SourceFavicon in command palette for connected sources * eagerly load shellscript syntax for instant bash highlighting * remove unused useEffect import from code-block ---------

Moritz Meyer committed 7 months ago

Commit 5fb89341 parent a98bafe5 files · +230 −3Browse files

Unified · Split

5 files
  1. apps/cloud/src/web/shell.tsx +13 −0
  2. apps/local/src/web/shell.tsx +13 −0
  3. packages/react/src/components/code-block.tsx +1 −1
  4. packages/react/src/components/command-palette.tsx +201 −0
  5. packages/react/src/lib/shiki.ts +2 −2
apps/cloud/src/web/shell.tsx+13−0
@@ -5,9 +5,21 @@ import { sourcesAtom } from "@executor/react/api/atoms"; · 4 unmodified lines
55 import { useScope } from "@executor/react/api/scope-context";
66 import { Button } from "@executor/react/components/button";
77 import { SourceFavicon } from "@executor/react/components/source-favicon";
8+import { CommandPalette } from "@executor/react/components/command-palette";
9+import { openApiSourcePlugin } from "@executor/plugin-openapi/react";
10+import { mcpSourcePlugin } from "@executor/plugin-mcp/react";
11+import { googleDiscoverySourcePlugin } from "@executor/plugin-google-discovery/react";
12+import { graphqlSourcePlugin } from "@executor/plugin-graphql/react";
813 import { AUTH_PATHS } from "../auth/api";
914 import { useAuth } from "./auth";
1015
16+const sourcePlugins = [
17+ openApiSourcePlugin,
18+ mcpSourcePlugin,
19+ googleDiscoverySourcePlugin,
20+ graphqlSourcePlugin,
21+];
22+
1123 // ── NavItem ──────────────────────────────────────────────────────────────
1224
1325 function NavItem(props: { to: string; label: string; active: boolean; onNavigate?: () => void }) {
@@ -194,6 +206,7 @@ export function Shell() { · 180 unmodified lines
194206
195207 return (
196208 <div className="flex h-screen overflow-hidden">
209+ <CommandPalette sourcePlugins={sourcePlugins} />
197210 {/* Desktop sidebar */}
198211 <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">
199212 <SidebarContent pathname={pathname} />
apps/local/src/web/shell.tsx+13−0
@@ -5,6 +5,18 @@ import { sourcesAtom, toolsAtom } from "@executor/react/api/atoms"; · 4 unmodified lines
55 import { useScope, useScopeInfo } from "@executor/react/api/scope-context";
66 import { Button } from "@executor/react/components/button";
77 import { SourceFavicon } from "@executor/react/components/source-favicon";
8+import { CommandPalette } from "@executor/react/components/command-palette";
9+import { openApiSourcePlugin } from "@executor/plugin-openapi/react";
10+import { mcpSourcePlugin } from "@executor/plugin-mcp/react";
11+import { googleDiscoverySourcePlugin } from "@executor/plugin-google-discovery/react";
12+import { graphqlSourcePlugin } from "@executor/plugin-graphql/react";
13+
14+const sourcePlugins = [
15+ openApiSourcePlugin,
16+ mcpSourcePlugin,
17+ googleDiscoverySourcePlugin,
18+ graphqlSourcePlugin,
19+];
820
921 // ── Env ─────────────────────────────────────────────────────────────────
1022
@@ -391,6 +403,7 @@ export function Shell() { · 380 unmodified lines
391403
392404 return (
393405 <div className="flex h-screen overflow-hidden">
406+ <CommandPalette sourcePlugins={sourcePlugins} />
394407 {/* Desktop sidebar */}
395408 <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">
396409 <SidebarContent
packages/react/src/components/code-block.tsx+1−1
@@ -1,4 +1,4 @@
1−import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
1+import { useCallback, useMemo, useState, type ReactNode } from "react";
22 import { jsx, jsxs, Fragment } from "react/jsx-runtime";
33 import { toJsxRuntime } from "hast-util-to-jsx-runtime";
44 import {
packages/react/src/components/command-palette.tsx+201−0
@@ -0,0 +1,201 @@
1+import { useCallback, useEffect, useMemo, useState } from "react";
2+import { useNavigate } from "@tanstack/react-router";
3+import { useAtomValue, Result } from "@effect-atom/atom-react";
4+import { PlusIcon } from "lucide-react";
5+import { SourceFavicon } from "./source-favicon";
6+import { sourcesAtom } from "../api/atoms";
7+import { useScope } from "../hooks/use-scope";
8+import type { SourcePlugin } from "../plugins/source-plugin";
9+import {
10+ CommandDialog,
11+ CommandEmpty,
12+ CommandGroup,
13+ CommandInput,
14+ CommandItem,
15+ CommandList,
16+ CommandSeparator,
17+ CommandShortcut,
18+} from "./command";
19+
20+// ---------------------------------------------------------------------------
21+// CommandPalette — global ⌘K navigator.
22+//
23+// Order of entries:
24+// 1. Connected sources (priority, shown first)
25+// 2. Add <Plugin> actions for each available source plugin
26+// 3. Popular sources (plugin presets)
27+// ---------------------------------------------------------------------------
28+
29+export function CommandPalette(props: { sourcePlugins: readonly SourcePlugin[] }) {
30+ const { sourcePlugins } = props;
31+ const [open, setOpen] = useState(false);
32+ const navigate = useNavigate();
33+ const scopeId = useScope();
34+ const sourcesResult = useAtomValue(sourcesAtom(scopeId));
35+
36+ // Toggle with ⌘K / Ctrl+K
37+ useEffect(() => {
38+ const onKeyDown = (e: KeyboardEvent) => {
39+ if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
40+ e.preventDefault();
41+ setOpen((o) => !o);
42+ }
43+ };
44+ document.addEventListener("keydown", onKeyDown);
45+ return () => document.removeEventListener("keydown", onKeyDown);
46+ }, []);
47+
48+ const connectedSources = useMemo(
49+ () =>
50+ Result.match(sourcesResult, {
51+ onInitial: () =>
52+ [] as Array<{
53+ id: string;
54+ name: string;
55+ kind: string;
56+ url?: string;
57+ runtime?: boolean;
58+ }>,
59+ onFailure: () =>
60+ [] as Array<{
61+ id: string;
62+ name: string;
63+ kind: string;
64+ url?: string;
65+ runtime?: boolean;
66+ }>,
67+ onSuccess: ({ value }) => value.filter((s) => !s.runtime),
68+ }),
69+ [sourcesResult],
70+ );
71+
72+ const presetEntries = useMemo(() => {
73+ const entries: Array<{
74+ pluginKey: string;
75+ pluginLabel: string;
76+ presetId: string;
77+ presetName: string;
78+ presetSummary?: string;
79+ presetUrl?: string;
80+ presetIcon?: string;
81+ }> = [];
82+ for (const plugin of sourcePlugins) {
83+ for (const preset of plugin.presets ?? []) {
84+ entries.push({
85+ pluginKey: plugin.key,
86+ pluginLabel: plugin.label,
87+ presetId: preset.id,
88+ presetName: preset.name,
89+ presetSummary: preset.summary,
90+ presetUrl: preset.url,
91+ presetIcon: preset.icon,
92+ });
93+ }
94+ }
95+ return entries;
96+ }, [sourcePlugins]);
97+
98+ const close = useCallback(() => setOpen(false), []);
99+
100+ const goToSource = useCallback(
101+ (id: string) => {
102+ close();
103+ void navigate({ to: "/sources/$namespace", params: { namespace: id } });
104+ },
105+ [close, navigate],
106+ );
107+
108+ const goToAdd = useCallback(
109+ (pluginKey: string) => {
110+ close();
111+ void navigate({
112+ to: "/sources/add/$pluginKey",
113+ params: { pluginKey },
114+ });
115+ },
116+ [close, navigate],
117+ );
118+
119+ const goToPreset = useCallback(
120+ (pluginKey: string, presetId: string, presetUrl?: string) => {
121+ close();
122+ const search: Record<string, string> = { preset: presetId };
123+ if (presetUrl) search.url = presetUrl;
124+ void navigate({
125+ to: "/sources/add/$pluginKey",
126+ params: { pluginKey },
127+ search,
128+ });
129+ },
130+ [close, navigate],
131+ );
132+
133+ return (
134+ <CommandDialog open={open} onOpenChange={setOpen}>
135+ <CommandInput placeholder="Search sources or jump to add…" />
136+ <CommandList>
137+ <CommandEmpty>No results found.</CommandEmpty>
138+
139+ {connectedSources.length > 0 && (
140+ <CommandGroup heading="Connected">
141+ {connectedSources.map((s) => (
142+ <CommandItem
143+ key={`source-${s.id}`}
144+ value={`connected ${s.name} ${s.id} ${s.kind}`}
145+ onSelect={() => goToSource(s.id)}
146+ >
147+ <SourceFavicon url={s.url} />
148+ <span className="flex-1 truncate">{s.name}</span>
149+ <CommandShortcut>{s.kind}</CommandShortcut>
150+ </CommandItem>
151+ ))}
152+ </CommandGroup>
153+ )}
154+
155+ {connectedSources.length > 0 && sourcePlugins.length > 0 && <CommandSeparator />}
156+
157+ {sourcePlugins.length > 0 && (
158+ <CommandGroup heading="Add source">
159+ {sourcePlugins.map((plugin) => (
160+ <CommandItem
161+ key={`add-${plugin.key}`}
162+ value={`add ${plugin.label} ${plugin.key}`}
163+ onSelect={() => goToAdd(plugin.key)}
164+ >
165+ <PlusIcon />
166+ <span className="flex-1 truncate">Add {plugin.label}</span>
167+ </CommandItem>
168+ ))}
169+ </CommandGroup>
170+ )}
171+
172+ {presetEntries.length > 0 && <CommandSeparator />}
173+
174+ {presetEntries.length > 0 && (
175+ <CommandGroup heading="Popular sources">
176+ {presetEntries.map((e) => (
177+ <CommandItem
178+ key={`preset-${e.pluginKey}-${e.presetId}`}
179+ value={`preset ${e.presetName} ${e.presetSummary ?? ""} ${e.pluginLabel}`}
180+ onSelect={() => goToPreset(e.pluginKey, e.presetId, e.presetUrl)}
181+ >
182+ {e.presetIcon ? (
183+ <img
184+ src={e.presetIcon}
185+ alt=""
186+ className="size-4 shrink-0 object-contain"
187+ loading="lazy"
188+ />
189+ ) : (
190+ <span aria-hidden className="size-4 shrink-0 rounded-sm bg-muted-foreground/20" />
191+ )}
192+ <span className="flex-1 truncate">{e.presetName}</span>
193+ <CommandShortcut>{e.pluginLabel}</CommandShortcut>
194+ </CommandItem>
195+ ))}
196+ </CommandGroup>
197+ )}
198+ </CommandList>
199+ </CommandDialog>
200+ );
201+}
packages/react/src/lib/shiki.ts+2−2
@@ -11,6 +11,7 @@ import langJavascript from "@shikijs/langs/javascript"; · 10 unmodified lines
1111 import langTsx from "@shikijs/langs/tsx";
1212 import langJsx from "@shikijs/langs/jsx";
1313 import langJson from "@shikijs/langs/json";
14+import langShellscript from "@shikijs/langs/shellscript";
1415 import githubDark from "@shikijs/themes/github-dark";
1516 import githubLight from "@shikijs/themes/github-light";
1617
@@ -76,7 +77,6 @@ const LANG_ALIASES: Record<string, SupportedLang> = { · 59 unmodified lines
7677 const LAZY_LANG_LOADERS: Partial<Record<SupportedLang, () => Promise<unknown>>> = {
7778 xml: () => import("@shikijs/langs/xml"),
7879 yaml: () => import("@shikijs/langs/yaml"),
79− shellscript: () => import("@shikijs/langs/shellscript"),
8080 python: () => import("@shikijs/langs/python"),
8181 html: () => import("@shikijs/langs/html"),
8282 css: () => import("@shikijs/langs/css"),
@@ -145,7 +145,7 @@ export function isSupportedLang(lang: string): boolean { · 62 unmodified lines
145145
146146 const highlighter: HighlighterCore = createHighlighterCoreSync({
147147 themes: [githubDark, githubLight],
148− langs: [langTypescript, langJavascript, langTsx, langJsx, langJson],
148+ langs: [langTypescript, langJavascript, langTsx, langJsx, langJson, langShellscript],
149149 engine: createJavaScriptRegexEngine({ forgiving: true }),
150150 });
151151