* 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 ---------
Unified · Split
| @@ -5,9 +5,21 @@ import { sourcesAtom } from "@executor/react/api/atoms"; · 4 unmodified lines | |||
| 5 | import { useScope } from "@executor/react/api/scope-context"; | 5 | import { useScope } from "@executor/react/api/scope-context"; |
| 6 | import { Button } from "@executor/react/components/button"; | 6 | import { Button } from "@executor/react/components/button"; |
| 7 | import { SourceFavicon } from "@executor/react/components/source-favicon"; | 7 | 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"; | ||
| 8 | import { AUTH_PATHS } from "../auth/api"; | 13 | import { AUTH_PATHS } from "../auth/api"; |
| 9 | import { useAuth } from "./auth"; | 14 | import { useAuth } from "./auth"; |
| 10 | 15 | ||
| 16 | const sourcePlugins = [ | ||
| 17 | openApiSourcePlugin, | ||
| 18 | mcpSourcePlugin, | ||
| 19 | googleDiscoverySourcePlugin, | ||
| 20 | graphqlSourcePlugin, | ||
| 21 | ]; | ||
| 22 | |||
| 11 | // ── NavItem ────────────────────────────────────────────────────────────── | 23 | // ── NavItem ────────────────────────────────────────────────────────────── |
| 12 | 24 | ||
| 13 | function NavItem(props: { to: string; label: string; active: boolean; onNavigate?: () => void }) { | 25 | function 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} /> |
| @@ -5,6 +5,18 @@ import { sourcesAtom, toolsAtom } from "@executor/react/api/atoms"; · 4 unmodified lines | |||
| 5 | import { useScope, useScopeInfo } from "@executor/react/api/scope-context"; | 5 | import { useScope, useScopeInfo } from "@executor/react/api/scope-context"; |
| 6 | import { Button } from "@executor/react/components/button"; | 6 | import { Button } from "@executor/react/components/button"; |
| 7 | import { SourceFavicon } from "@executor/react/components/source-favicon"; | 7 | 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 | ]; | ||
| 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 | <SidebarContent | 409 | <SidebarContent |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; | 1 | import { useCallback, useMemo, useState, type ReactNode } from "react"; |
| 2 | import { jsx, jsxs, Fragment } from "react/jsx-runtime"; | 2 | import { jsx, jsxs, Fragment } from "react/jsx-runtime"; |
| 3 | import { toJsxRuntime } from "hast-util-to-jsx-runtime"; | 3 | import { toJsxRuntime } from "hast-util-to-jsx-runtime"; |
| 4 | import { | 4 | import { |
| @@ -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 | } | ||
| @@ -11,6 +11,7 @@ import langJavascript from "@shikijs/langs/javascript"; · 10 unmodified lines | |||
| 11 | import langTsx from "@shikijs/langs/tsx"; | 11 | import langTsx from "@shikijs/langs/tsx"; |
| 12 | import langJsx from "@shikijs/langs/jsx"; | 12 | import langJsx from "@shikijs/langs/jsx"; |
| 13 | import langJson from "@shikijs/langs/json"; | 13 | import langJson from "@shikijs/langs/json"; |
| 14 | import langShellscript from "@shikijs/langs/shellscript"; | ||
| 14 | import githubDark from "@shikijs/themes/github-dark"; | 15 | import githubDark from "@shikijs/themes/github-dark"; |
| 15 | import githubLight from "@shikijs/themes/github-light"; | 16 | import githubLight from "@shikijs/themes/github-light"; |
| 16 | 17 | ||
| @@ -76,7 +77,6 @@ const LANG_ALIASES: Record<string, SupportedLang> = { · 59 unmodified lines | |||
| 76 | const LAZY_LANG_LOADERS: Partial<Record<SupportedLang, () => Promise<unknown>>> = { | 77 | const 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 | ||
| 146 | const highlighter: HighlighterCore = createHighlighterCoreSync({ | 146 | const 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 | ||