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
5import { useScope } from "@executor/react/api/scope-context";5import { useScope } from "@executor/react/api/scope-context";
6import { Button } from "@executor/react/components/button";6import { Button } from "@executor/react/components/button";
7import { SourceFavicon } from "@executor/react/components/source-favicon";7import { SourceFavicon } from "@executor/react/components/source-favicon";
8import { CommandPalette } from "@executor/react/components/command-palette";
9import { openApiSourcePlugin } from "@executor/plugin-openapi/react";
10import { mcpSourcePlugin } from "@executor/plugin-mcp/react";
11import { googleDiscoverySourcePlugin } from "@executor/plugin-google-discovery/react";
12import { graphqlSourcePlugin } from "@executor/plugin-graphql/react";
8import { AUTH_PATHS } from "../auth/api";13import { AUTH_PATHS } from "../auth/api";
9import { useAuth } from "./auth";14import { useAuth } from "./auth";
10 15
16const sourcePlugins = [
17 openApiSourcePlugin,
18 mcpSourcePlugin,
19 googleDiscoverySourcePlugin,
20 graphqlSourcePlugin,
21];
22
11// ── NavItem ──────────────────────────────────────────────────────────────23// ── NavItem ──────────────────────────────────────────────────────────────
12 24
13function NavItem(props: { to: string; label: string; active: boolean; onNavigate?: () => void }) {25function NavItem(props: { to: string; label: string; active: boolean; onNavigate?: () => void }) {
@@ -194,6 +206,7 @@ export function Shell() { · 180 unmodified lines
194 206
195 return (207 return (
196 <div className="flex h-screen overflow-hidden">208 <div className="flex h-screen overflow-hidden">
209 <CommandPalette sourcePlugins={sourcePlugins} />
197 {/* Desktop sidebar */}210 {/* Desktop sidebar */}
198 <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">211 <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">
199 <SidebarContent pathname={pathname} />212 <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
5import { useScope, useScopeInfo } from "@executor/react/api/scope-context";5import { useScope, useScopeInfo } from "@executor/react/api/scope-context";
6import { Button } from "@executor/react/components/button";6import { Button } from "@executor/react/components/button";
7import { SourceFavicon } from "@executor/react/components/source-favicon";7import { SourceFavicon } from "@executor/react/components/source-favicon";
8import { CommandPalette } from "@executor/react/components/command-palette";
9import { openApiSourcePlugin } from "@executor/plugin-openapi/react";
10import { mcpSourcePlugin } from "@executor/plugin-mcp/react";
11import { googleDiscoverySourcePlugin } from "@executor/plugin-google-discovery/react";
12import { graphqlSourcePlugin } from "@executor/plugin-graphql/react";
13
14const sourcePlugins = [
15 openApiSourcePlugin,
16 mcpSourcePlugin,
17 googleDiscoverySourcePlugin,
18 graphqlSourcePlugin,
19];
8 20
9// ── Env ─────────────────────────────────────────────────────────────────21// ── Env ─────────────────────────────────────────────────────────────────
10 22
@@ -391,6 +403,7 @@ export function Shell() { · 380 unmodified lines
391 403
392 return (404 return (
393 <div className="flex h-screen overflow-hidden">405 <div className="flex h-screen overflow-hidden">
406 <CommandPalette sourcePlugins={sourcePlugins} />
394 {/* Desktop sidebar */}407 {/* Desktop sidebar */}
395 <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">408 <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">
396 <SidebarContent409 <SidebarContent
packages/react/src/components/code-block.tsx+1−1
@@ -1,4 +1,4 @@
1import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";1import { useCallback, useMemo, useState, type ReactNode } from "react";
2import { jsx, jsxs, Fragment } from "react/jsx-runtime";2import { jsx, jsxs, Fragment } from "react/jsx-runtime";
3import { toJsxRuntime } from "hast-util-to-jsx-runtime";3import { toJsxRuntime } from "hast-util-to-jsx-runtime";
4import {4import {
packages/react/src/components/command-palette.tsx+201−0
@@ -0,0 +1,201 @@
1import { useCallback, useEffect, useMemo, useState } from "react";
2import { useNavigate } from "@tanstack/react-router";
3import { useAtomValue, Result } from "@effect-atom/atom-react";
4import { PlusIcon } from "lucide-react";
5import { SourceFavicon } from "./source-favicon";
6import { sourcesAtom } from "../api/atoms";
7import { useScope } from "../hooks/use-scope";
8import type { SourcePlugin } from "../plugins/source-plugin";
9import {
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
29export 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
11import langTsx from "@shikijs/langs/tsx";11import langTsx from "@shikijs/langs/tsx";
12import langJsx from "@shikijs/langs/jsx";12import langJsx from "@shikijs/langs/jsx";
13import langJson from "@shikijs/langs/json";13import langJson from "@shikijs/langs/json";
14import langShellscript from "@shikijs/langs/shellscript";
14import githubDark from "@shikijs/themes/github-dark";15import githubDark from "@shikijs/themes/github-dark";
15import githubLight from "@shikijs/themes/github-light";16import githubLight from "@shikijs/themes/github-light";
16 17
@@ -76,7 +77,6 @@ const LANG_ALIASES: Record<string, SupportedLang> = { · 59 unmodified lines
76const LAZY_LANG_LOADERS: Partial<Record<SupportedLang, () => Promise<unknown>>> = {77const LAZY_LANG_LOADERS: Partial<Record<SupportedLang, () => Promise<unknown>>> = {
77 xml: () => import("@shikijs/langs/xml"),78 xml: () => import("@shikijs/langs/xml"),
78 yaml: () => import("@shikijs/langs/yaml"),79 yaml: () => import("@shikijs/langs/yaml"),
79 shellscript: () => import("@shikijs/langs/shellscript"),
80 python: () => import("@shikijs/langs/python"),80 python: () => import("@shikijs/langs/python"),
81 html: () => import("@shikijs/langs/html"),81 html: () => import("@shikijs/langs/html"),
82 css: () => import("@shikijs/langs/css"),82 css: () => import("@shikijs/langs/css"),
@@ -145,7 +145,7 @@ export function isSupportedLang(lang: string): boolean { · 62 unmodified lines
145 145
146const highlighter: HighlighterCore = createHighlighterCoreSync({146const highlighter: HighlighterCore = createHighlighterCoreSync({
147 themes: [githubDark, githubLight],147 themes: [githubDark, githubLight],
148 langs: [langTypescript, langJavascript, langTsx, langJsx, langJson],148 langs: [langTypescript, langJavascript, langTsx, langJsx, langJson, langShellscript],
149 engine: createJavaScriptRegexEngine({ forgiving: true }),149 engine: createJavaScriptRegexEngine({ forgiving: true }),
150});150});
151 151