History

fix plugin react hot reload boundaries

Rhys Sullivan committed 7 months ago

Commit 2aa97691 parent 95dcccd22 files · +1205 −1988Browse files

Unified · Split

22 files
  1. apps/web/vite.config.ts +18 −0
  2. packages/clients/react/src/plugins/components/source-tool-explorer-search.ts +24 −0
  3. packages/clients/react/src/plugins/components/source-tool-explorer.tsx +106 −440
  4. packages/clients/react/src/plugins/components/source-tool-workbench.tsx +755 −0
  5. packages/clients/react/src/plugins/index.ts +10 −1
  6. plugins/google-discovery/react/components.tsx +19 −74
  7. plugins/google-discovery/react/icons.ts +44 −0
  8. plugins/google-discovery/react/index.ts +35 −0
  9. plugins/google-discovery/react/package.json +4 −2
  10. plugins/google-discovery/react/tsconfig.json +2 −1
  11. plugins/graphql/react/components.tsx +26 −756
  12. plugins/graphql/react/index.ts +36 −0
  13. plugins/graphql/react/package.json +3 −2
  14. plugins/graphql/react/tsconfig.json +2 −1
  15. plugins/mcp/react/components.tsx +18 −38
  16. plugins/mcp/react/index.ts +30 −0
  17. plugins/mcp/react/package.json +3 −2
  18. plugins/mcp/react/tsconfig.json +2 −1
  19. plugins/openapi/react/components.tsx +34 −667
  20. plugins/openapi/react/index.ts +29 −0
  21. plugins/openapi/react/package.json +3 −2
  22. plugins/openapi/react/tsconfig.json +2 −1
apps/web/vite.config.ts+18−0
@@ -23,6 +23,16 @@ const githubUrl = (executorPackage.homepage ?? repositoryUrl ?? "https://github. · 22 unmodified lines
2323 .replace(/^git\+/, "")
2424 .replace(/\.git$/, "");
2525
26+const webPackage = JSON.parse(
27+ readFileSync(new URL("./package.json", import.meta.url), "utf8"),
28+) as {
29+ dependencies?: Record<string, string>;
30+};
31+
32+const workspaceExecutorPackages = Object.keys(webPackage.dependencies ?? {}).filter(
33+ (name) => name.startsWith("@executor/"),
34+);
35+
2636 export default defineConfig({
2737 root: "src",
2838 plugins: [
@@ -47,7 +57,15 @@ export default defineConfig({ · 18 unmodified lines
4757 outDir: "../dist",
4858 emptyOutDir: true,
4959 },
60+ optimizeDeps: {
61+ exclude: workspaceExecutorPackages,
62+ },
5063 server: {
5164 port: 8788,
65+ watch: {
66+ ignored: [
67+ "!**/node_modules/@executor/**",
68+ ],
69+ },
5270 },
5371 });
packages/clients/react/src/plugins/components/source-tool-explorer-search.ts+24−0
@@ -0,0 +1,24 @@
1+export type SourceToolExplorerSearch = {
2+ tab: "model" | "discover";
3+ tool?: string;
4+ query?: string;
5+};
6+
7+const sourceToolExplorerTabs = ["model", "discover"] as const;
8+
9+export const parseSourceToolExplorerSearch = (
10+ search: Record<string, unknown>,
11+): SourceToolExplorerSearch => ({
12+ tab:
13+ typeof search.tab === "string"
14+ && sourceToolExplorerTabs.includes(
15+ search.tab as SourceToolExplorerSearch["tab"],
16+ )
17+ ? (search.tab as SourceToolExplorerSearch["tab"])
18+ : "model",
19+ tool:
20+ typeof search.tool === "string" && search.tool.length > 0
21+ ? search.tool
22+ : undefined,
23+ query: typeof search.query === "string" ? search.query : undefined,
24+});
packages/clients/react/src/plugins/components/source-tool-explorer.tsx+106−440
@@ -1,268 +1,24 @@
1−import { startTransition, useEffect, useState, type ReactNode } from "react";
1+import { startTransition, useEffect, type ReactNode } from "react";
22
3−import type { Loadable } from "../../core/types";
4−import type {
5− SourceInspection,
6− SourceInspectionToolDetail,
7−} from "@executor/platform-sdk/schema";
83 import {
94 useSourceDiscovery,
105 useSourceInspection,
116 useSourceToolDetail,
127 } from "../../hooks/sources";
13−import { Badge, MethodBadge } from "./badge";
14−import { DocumentPanel } from "./document-panel";
15−import { EmptyState, LoadableBlock } from "./loadable";
16−import { Markdown } from "./markdown";
8+import { Badge } from "./badge";
9+import { LoadableBlock } from "./loadable";
1710 import {
18− IconCheck,
19− IconCopy,
20− IconSearch,
21− IconTool,
22−} from "./icons";
11+ SourceToolDiscoveryPanel,
12+ SourceToolModelWorkbench,
13+} from "./source-tool-workbench";
14+import type { SourceToolExplorerSearch } from "./source-tool-explorer-search";
2315 import { cn } from "../lib/cn";
2416
25−export type SourceToolExplorerSearch = {
26− tab: "model" | "discover";
27− tool?: string;
28− query?: string;
29−};
30−
31−const sourceToolExplorerTabs = ["model", "discover"] as const;
32−
33−export const parseSourceToolExplorerSearch = (
34− search: Record<string, unknown>,
35−): SourceToolExplorerSearch => ({
36− tab:
37− typeof search.tab === "string"
38− && sourceToolExplorerTabs.includes(
39− search.tab as SourceToolExplorerSearch["tab"],
40− )
41− ? (search.tab as SourceToolExplorerSearch["tab"])
42− : "model",
43− tool:
44− typeof search.tool === "string" && search.tool.length > 0
45− ? search.tool
46− : undefined,
47− query: typeof search.query === "string" ? search.query : undefined,
48−});
49−
50−const highlight = (text: string, query: string) => {
51− const trimmed = query.trim().toLowerCase();
52− if (!trimmed) {
53− return text;
54− }
55−
56− const index = text.toLowerCase().indexOf(trimmed);
57− if (index === -1) {
58− return text;
59− }
60−
61− return (
62− <>
63− <span>{text.slice(0, index)}</span>
64− <mark className="rounded-sm bg-primary/20 px-px text-foreground">
65− {text.slice(index, index + trimmed.length)}
66− </mark>
67− <span>{text.slice(index + trimmed.length)}</span>
68− </>
69− );
70−};
71−
72−const ToolDetailPanel = (props: {
73− detail: SourceInspectionToolDetail;
74−}) => {
75− const [copied, setCopied] = useState<string | null>(null);
76− const copy = async (value: string, key: string) => {
77− await navigator.clipboard.writeText(value);
78− setCopied(key);
79− window.setTimeout(() => setCopied(null), 1200);
80− };
81−
82− return (
83− <div className="space-y-4">
84− <div className="flex items-start justify-between gap-3 rounded-xl border border-border/70 bg-card/60 p-4">
85− <div className="min-w-0">
86− <div className="flex items-center gap-2">
87− <h3 className="truncate font-mono text-sm font-medium text-foreground">
88− {props.detail.summary.path}
89− </h3>
90− <button
91− type="button"
92− onClick={() => {
93− void copy(props.detail.summary.path, "path");
94− }}
95− className="rounded p-1 text-muted-foreground/50 transition-colors hover:text-foreground"
96− >
97− {copied === "path" ? <IconCheck /> : <IconCopy />}
98− </button>
99− </div>
100− <div className="mt-2 flex flex-wrap items-center gap-2">
101− {props.detail.summary.method && (
102− <MethodBadge method={props.detail.summary.method} />
103− )}
104− {props.detail.summary.group && (
105− <Badge variant="outline">{props.detail.summary.group}</Badge>
106− )}
107− {props.detail.summary.protocol && (
108− <Badge variant="muted">{props.detail.summary.protocol}</Badge>
109− )}
110− </div>
111− </div>
112− </div>
113−
114− {props.detail.summary.description && (
115− <div className="rounded-xl border border-border/70 bg-card/40 p-4">
116− <Markdown>{props.detail.summary.description}</Markdown>
117− </div>
118− )}
119−
120− <div className="grid gap-4 xl:grid-cols-2">
121− <DocumentPanel
122− title="Input"
123− body={
124− props.detail.contract.input.typeDeclaration
125− ?? props.detail.contract.input.typePreview
126− ?? null
127− }
128− lang="typescript"
129− empty="No input."
130− />
131− <DocumentPanel
132− title="Output"
133− body={
134− props.detail.contract.output.typeDeclaration
135− ?? props.detail.contract.output.typePreview
136− ?? null
137− }
138− lang="typescript"
139− empty="No output."
140− />
141− </div>
142−
143− <DocumentPanel
144− title="Call Signature"
145− body={props.detail.contract.callSignature}
146− lang="typescript"
147− empty="No signature."
148− />
149−
150− <div className="grid gap-4 xl:grid-cols-2">
151− <DocumentPanel
152− title="Input Schema"
153− body={props.detail.contract.input.schemaJson}
154− empty="No input schema."
155− compact
156− />
157− <DocumentPanel
158− title="Output Schema"
159− body={props.detail.contract.output.schemaJson}
160− empty="No output schema."
161− compact
162− />
163− </div>
164− </div>
165− );
166−};
167−
168−const DiscoveryPanel = (props: {
169− sourceId: string;
170− query: string;
171− onOpenTool: (toolPath: string) => void;
172− onQueryChange: (query: string) => void;
173−}) => {
174− const [draftQuery, setDraftQuery] = useState(props.query);
175− const results = useSourceDiscovery({
176− sourceId: props.sourceId,
177− query: props.query,
178− limit: 20,
179− });
180−
181− useEffect(() => {
182− setDraftQuery(props.query);
183− }, [props.query]);
184−
185− return (
186− <div className="space-y-4">
187− <form
188− className="flex items-center gap-2"
189− onSubmit={(event) => {
190− event.preventDefault();
191− props.onQueryChange(draftQuery.trim());
192− }}
193− >
194− <div className="relative flex-1">
195− <IconSearch className="absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/40" />
196− <input
197− value={draftQuery}
198− onChange={(event) => setDraftQuery(event.target.value)}
199− placeholder="Search tools..."
200− className="h-9 w-full rounded-lg border border-input bg-background pl-9 pr-3 text-sm outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25"
201− />
202− </div>
203− <button
204− type="submit"
205− className="inline-flex h-9 items-center rounded-lg border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50"
206− >
207− Search
208− </button>
209− </form>
210−
211− <LoadableBlock loadable={results} loading="Searching tools...">
212− {(loaded) =>
213− loaded.query.length === 0 ? (
214− <EmptyState
215− title="Search this source"
216− description="Enter a query to find relevant tools."
217− />
218− ) : loaded.results.length === 0 ? (
219− <EmptyState
220− title="No results"
221− description="Try different search terms."
222− />
223− ) : (
224− <div className="space-y-2">
225− {loaded.results.map((item, index) => (
226− <button
227− key={item.path}
228− type="button"
229− onClick={() => props.onOpenTool(item.path)}
230− className="w-full rounded-xl border border-border/70 bg-card/50 p-3 text-left transition-colors hover:border-primary/30 hover:bg-card"
231− >
232− <div className="flex items-center justify-between gap-3">
233− <div className="flex min-w-0 items-center gap-2">
234− <span className="inline-flex size-5 items-center justify-center rounded-md bg-muted text-[10px] text-muted-foreground">
235− {index + 1}
236− </span>
237− <span className="truncate font-mono text-xs text-foreground">
238− {item.path}
239− </span>
240− </div>
241− <span className="font-mono text-[11px] text-muted-foreground/60">
242− {item.score.toFixed(2)}
243− </span>
244− </div>
245− {item.description && (
246− <div className="mt-2 text-xs text-muted-foreground">
247− {item.description}
248− </div>
249− )}
250− </button>
251− ))}
252− </div>
253− )
254− }
255− </LoadableBlock>
256− </div>
257− );
258−};
259−
26017 export const SourceToolExplorer = (props: {
26118 sourceId: string;
26219 title: string;
26320 kind: string;
26421 search: SourceToolExplorerSearch;
265− modelLayout?: "split" | "detail";
26622 navigate?: (search: {
26723 tab: "model" | "discover";
26824 tool?: string;
@@ -275,9 +31,12 @@ export const SourceToolExplorer = (props: { · 6 unmodified lines
27531 const tab = props.search.tab === "discover" ? "discover" : "model";
27632 const toolPath = props.search.tool ?? null;
27733 const detail = useSourceToolDetail(props.sourceId, tab === "model" ? toolPath : null);
278− const [filter, setFilter] = useState("");
34+ const discovery = useSourceDiscovery({
35+ sourceId: props.sourceId,
36+ query: props.search.query ?? "",
37+ limit: 20,
38+ });
27939 const navigate = props.navigate;
280− const modelLayout = props.modelLayout ?? "split";
28140
28241 useEffect(() => {
28342 if (
@@ -310,206 +69,113 @@ export const SourceToolExplorer = (props: { · 26 unmodified lines
31069 loadedInspection.tools.find((candidate) => candidate.path === toolPath)
31170 ?? loadedInspection.tools[0]
31271 ?? null;
313− const visibleTools = loadedInspection.tools.filter((tool) => {
314− const query = filter.trim().toLowerCase();
315− if (!query) {
316− return true;
317− }
318−
319− return [
320− tool.path,
321− tool.method ?? "",
322− tool.inputTypePreview ?? "",
323− tool.outputTypePreview ?? "",
324− ]
325− .join(" ")
326− .toLowerCase()
327− .includes(query);
328− });
32972
33073 return (
331− <div className="space-y-4">
332− <div className="rounded-2xl border border-border bg-card px-5 py-4">
333− <div className="flex items-start justify-between gap-4">
334− <div className="min-w-0">
335− <div className="flex flex-wrap items-center gap-2">
336− <h2 className="truncate text-lg font-semibold text-foreground">
337− {props.title}
338− </h2>
339− <Badge variant="outline">{props.kind}</Badge>
340− <Badge variant="muted">
341− {loadedInspection.toolCount} tool{loadedInspection.toolCount === 1 ? "" : "s"}
342− </Badge>
343− </div>
344− {props.summary && (
345− <div className="mt-3 text-sm text-muted-foreground">
346− {props.summary}
347− </div>
348− )}
349− </div>
350− <div className="flex items-center gap-2">
351− <div className="inline-flex rounded-lg bg-muted p-0.5">
352− {(["model", "discover"] as const).map((entry) => (
353− <button
354− key={entry}
355− type="button"
356− onClick={() => {
357− if (!navigate) {
358− return;
359− }
360−
361− startTransition(() => {
362− void navigate({
363− tab: entry,
364− tool: entry === "model" ? (selectedTool?.path ?? undefined) : props.search.tool,
365− query: props.search.query,
366− });
74+ <div className="flex h-full flex-col overflow-hidden">
75+ <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background/95 px-4 backdrop-blur-sm">
76+ <div className="flex min-w-0 items-center gap-3">
77+ <h2 className="truncate text-sm font-semibold text-foreground">
78+ {props.title}
79+ </h2>
80+ <Badge variant="outline">{props.kind}</Badge>
81+ </div>
82+ <div className="flex items-center gap-2">
83+ <div className="flex items-center gap-0.5 rounded-lg bg-muted/50 p-0.5">
84+ {(["model", "discover"] as const).map((tabId) => (
85+ <button
86+ key={tabId}
87+ type="button"
88+ onClick={() => {
89+ if (!navigate) {
90+ return;
91+ }
92+
93+ startTransition(() => {
94+ void navigate({
95+ tab: tabId,
96+ tool:
97+ tabId === "model"
98+ ? (selectedTool?.path ?? undefined)
99+ : props.search.tool,
100+ query: props.search.query,
367101 });
368− }}
369− className={cn(
370− "rounded-md px-3 py-1 text-xs font-medium transition-colors",
371− tab === entry
372− ? "bg-background text-foreground shadow-sm"
373− : "text-muted-foreground hover:text-foreground",
374− )}
375− >
376− {entry === "model" ? "Tools" : "Search"}
377− </button>
378− ))}
379− </div>
380− {props.actions}
102+ });
103+ }}
104+ className={cn(
105+ "rounded-md px-3 py-1 text-[12px] font-medium transition-colors",
106+ tabId === tab
107+ ? "bg-background text-foreground shadow-sm"
108+ : "text-muted-foreground hover:text-foreground",
109+ )}
110+ >
111+ {tabId === "model" ? "Tools" : "Search"}
112+ </button>
113+ ))}
381114 </div>
115+ {props.actions}
382116 </div>
383117 </div>
384118
385− {tab === "discover" ? (
386− <DiscoveryPanel
387− sourceId={props.sourceId}
388− query={props.search.query ?? ""}
389− onQueryChange={(query) => {
390− if (!navigate) {
391− return;
392− }
119+ {props.summary && (
120+ <div className="shrink-0 border-b border-border bg-card/30 px-4 py-2">
121+ {props.summary}
122+ </div>
123+ )}
393124
394− startTransition(() => {
395− void navigate({
396− tab: "discover",
397− tool: props.search.tool,
398− query,
399− });
400− });
401− }}
402− onOpenTool={(path) => {
403− if (!navigate) {
404− return;
405− }
125+ <div className="flex min-h-0 flex-1 overflow-hidden">
126+ {tab === "model" ? (
127+ <SourceToolModelWorkbench
128+ bundle={loadedInspection}
129+ detail={detail}
130+ selectedToolPath={selectedTool?.path ?? null}
131+ onSelectTool={(nextToolPath) => {
132+ if (!navigate) {
133+ return;
134+ }
406135
407− startTransition(() => {
408− void navigate({
409− tab: "model",
410− tool: path,
411− query: props.search.query,
136+ startTransition(() => {
137+ void navigate({
138+ tab: "model",
139+ tool: nextToolPath,
140+ query: props.search.query,
141+ });
412142 });
413− });
414− }}
415− />
416− ) : (
417− modelLayout === "detail" ? (
418− <div className="rounded-2xl border border-border bg-card p-4">
419− <LoadableBlock loadable={detail} loading="Loading tool...">
420− {(loadedDetail) =>
421− loadedDetail ? (
422− <ToolDetailPanel detail={loadedDetail} />
423− ) : (
424− <EmptyState
425− title="Tool unavailable"
426− description="The selected tool could not be loaded."
427− />
428− )
429− }
430− </LoadableBlock>
431− </div>
143+ }}
144+ sourceId={props.sourceId}
145+ />
432146 ) : (
433− <div className="flex min-h-0 flex-col gap-4 md:flex-row">
434− <div
435− className={cn(
436− "space-y-3 rounded-2xl border border-border bg-card p-4 md:w-72 md:shrink-0 xl:w-80",
437− selectedTool && "order-2 md:order-1",
438− )}
439− >
440− <div className="relative">
441− <IconSearch className="absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/40" />
442− <input
443− value={filter}
444− onChange={(event) => setFilter(event.target.value)}
445− placeholder="Filter tools..."
446− className="h-9 w-full rounded-lg border border-input bg-background pl-9 pr-3 text-sm outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25"
447− />
448− </div>
449− <div className="space-y-1">
450− {visibleTools.length === 0 ? (
451− <EmptyState
452− title="No tools match"
453− description="Try a different filter."
454− />
455− ) : (
456− visibleTools.map((tool) => (
457− <button
458− key={tool.path}
459− type="button"
460− onClick={() => {
461− if (!navigate) {
462− return;
463− }
147+ <SourceToolDiscoveryPanel
148+ discovery={discovery}
149+ initialQuery={props.search.query ?? ""}
150+ onSubmitQuery={(query) => {
151+ if (!navigate) {
152+ return;
153+ }
464154
465− startTransition(() => {
466− void navigate({
467− tab: "model",
468− tool: tool.path,
469− query: props.search.query,
470− });
471− });
472− }}
473− className={cn(
474− "flex w-full items-center gap-2 rounded-lg border px-3 py-2 text-left transition-colors",
475− selectedTool?.path === tool.path
476− ? "border-primary/30 bg-primary/8"
477− : "border-transparent bg-muted/30 hover:border-border hover:bg-card",
478− )}
479− >
480− <IconTool className="size-3.5 shrink-0 text-muted-foreground/60" />
481− <span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">
482− {highlight(tool.path, filter)}
483− </span>
484− {tool.method && <MethodBadge method={tool.method} />}
485− </button>
486− ))
487− )}
488− </div>
489− </div>
155+ startTransition(() => {
156+ void navigate({
157+ tab: "discover",
158+ tool: props.search.tool,
159+ query,
160+ });
161+ });
162+ }}
163+ onOpenTool={(nextToolPath) => {
164+ if (!navigate) {
165+ return;
166+ }
490167
491− <div
492− className={cn(
493− "min-w-0 flex-1 rounded-2xl border border-border bg-card p-4",
494− selectedTool && "order-1 md:order-2",
495− )}
496− >
497− <LoadableBlock loadable={detail} loading="Loading tool...">
498− {(loadedDetail) =>
499− loadedDetail ? (
500− <ToolDetailPanel detail={loadedDetail} />
501− ) : (
502− <EmptyState
503− title="Select a tool"
504− description="Choose a tool from the list to inspect its contract."
505− />
506− )
507− }
508− </LoadableBlock>
509− </div>
510− </div>
511− )
512− )}
168+ startTransition(() => {
169+ void navigate({
170+ tab: "model",
171+ tool: nextToolPath,
172+ query: props.search.query,
173+ });
174+ });
175+ }}
176+ />
177+ )}
178+ </div>
513179 </div>
514180 );
515181 }}
packages/clients/react/src/plugins/components/source-tool-workbench.tsx+755−0
@@ -0,0 +1,755 @@
1+import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2+
3+import type { Loadable } from "../../core/types";
4+import {
5+ usePrefetchToolDetail,
6+} from "../../hooks/sources";
7+import type {
8+ SourceInspection,
9+ SourceInspectionToolDetail,
10+} from "@executor/platform-sdk/schema";
11+import { Badge, MethodBadge } from "./badge";
12+import { CodeBlock } from "./code-block";
13+import { DocumentPanel } from "./document-panel";
14+import { EmptyState, LoadableBlock } from "./loadable";
15+import { Markdown } from "./markdown";
16+import {
17+ IconCheck,
18+ IconChevron,
19+ IconClose,
20+ IconCopy,
21+ IconFolder,
22+ IconSearch,
23+ IconTool,
24+} from "./icons";
25+import { cn } from "../lib/cn";
26+
27+type ToolTreeNode = {
28+ segment: string;
29+ tool?: SourceInspectionToolDetail["summary"] | SourceInspection["tools"][number];
30+ children: Map<string, ToolTreeNode>;
31+};
32+
33+export type SourceToolDiscoveryResult = {
34+ query: string;
35+ results: ReadonlyArray<{
36+ path: string;
37+ score: number;
38+ description?: string | null;
39+ }>;
40+};
41+
42+export type SourceToolDetailPanelProps = {
43+ detail: SourceInspectionToolDetail;
44+ renderHeaderMeta?: (detail: SourceInspectionToolDetail) => ReactNode;
45+ renderSchemaExtras?: (detail: SourceInspectionToolDetail) => ReactNode;
46+};
47+
48+export type SourceToolModelWorkbenchProps = {
49+ bundle: SourceInspection;
50+ detail: Loadable<SourceInspectionToolDetail | null>;
51+ selectedToolPath: string | null;
52+ onSelectTool: (toolPath: string) => void;
53+ sourceId: string;
54+ renderDetail?: (detail: SourceInspectionToolDetail) => ReactNode;
55+};
56+
57+export type SourceToolDiscoveryPanelProps<
58+ TResult extends SourceToolDiscoveryResult = SourceToolDiscoveryResult,
59+> = {
60+ discovery: Loadable<TResult>;
61+ initialQuery: string;
62+ onSubmitQuery: (query: string) => void;
63+ onOpenTool: (toolPath: string) => void;
64+};
65+
66+const buildToolTree = (tools: SourceInspection["tools"]): ToolTreeNode => {
67+ const root: ToolTreeNode = {
68+ segment: "",
69+ children: new Map(),
70+ };
71+
72+ for (const tool of tools) {
73+ const parts = tool.path.split(".");
74+ let node = root;
75+
76+ for (const part of parts) {
77+ const existing = node.children.get(part);
78+ if (existing) {
79+ node = existing;
80+ continue;
81+ }
82+
83+ const next: ToolTreeNode = {
84+ segment: part,
85+ children: new Map(),
86+ };
87+ node.children.set(part, next);
88+ node = next;
89+ }
90+
91+ node.tool = tool;
92+ }
93+
94+ return root;
95+};
96+
97+const countToolLeaves = (node: ToolTreeNode): number => {
98+ let count = node.tool ? 1 : 0;
99+ for (const child of node.children.values()) {
100+ count += countToolLeaves(child);
101+ }
102+ return count;
103+};
104+
105+const highlightMatch = (text: string, search: string) => {
106+ if (!search.trim()) {
107+ return text;
108+ }
109+
110+ const terms = search.trim().toLowerCase().split(/\s+/);
111+ const lowerText = text.toLowerCase();
112+ const ranges: Array<[number, number]> = [];
113+
114+ for (const term of terms) {
115+ let index = 0;
116+ while (index < lowerText.length) {
117+ const found = lowerText.indexOf(term, index);
118+ if (found === -1) {
119+ break;
120+ }
121+ ranges.push([found, found + term.length]);
122+ index = found + 1;
123+ }
124+ }
125+
126+ if (ranges.length === 0) {
127+ return text;
128+ }
129+
130+ ranges.sort((left, right) => left[0] - right[0]);
131+ const merged: Array<[number, number]> = [ranges[0]!];
132+ for (let index = 1; index < ranges.length; index++) {
133+ const last = merged[merged.length - 1]!;
134+ const current = ranges[index]!;
135+ if (current[0] <= last[1]) {
136+ last[1] = Math.max(last[1], current[1]);
137+ } else {
138+ merged.push(current);
139+ }
140+ }
141+
142+ const parts: Array<{ text: string; highlighted: boolean }> = [];
143+ let cursor = 0;
144+ for (const [start, end] of merged) {
145+ if (cursor < start) {
146+ parts.push({ text: text.slice(cursor, start), highlighted: false });
147+ }
148+ parts.push({ text: text.slice(start, end), highlighted: true });
149+ cursor = end;
150+ }
151+ if (cursor < text.length) {
152+ parts.push({ text: text.slice(cursor), highlighted: false });
153+ }
154+
155+ return (
156+ <>
157+ {parts.map((part, index) =>
158+ part.highlighted ? (
159+ <mark key={index} className="rounded-sm bg-primary/20 px-px text-foreground">
160+ {part.text}
161+ </mark>
162+ ) : (
163+ <span key={index}>{part.text}</span>
164+ ),
165+ )}
166+ </>
167+ );
168+};
169+
170+const CopyButton = (props: {
171+ text: string;
172+ field: string;
173+ copiedField: string | null;
174+ onCopy: (text: string, field: string) => void | Promise<void>;
175+}) => (
176+ <button
177+ type="button"
178+ onClick={() => {
179+ void props.onCopy(props.text, props.field);
180+ }}
181+ className="shrink-0 rounded p-1 text-muted-foreground/30 transition-colors hover:text-muted-foreground"
182+ title={`Copy ${props.field}`}
183+ >
184+ {props.copiedField === props.field ? <IconCheck /> : <IconCopy />}
185+ </button>
186+);
187+
188+const SourceToolTree = (props: {
189+ tools: SourceInspection["tools"];
190+ selectedToolPath: string | null;
191+ onSelectTool: (path: string) => void;
192+ search: string;
193+ isFiltered: boolean;
194+ sourceId: string;
195+}) => {
196+ const tree = useMemo(() => buildToolTree(props.tools), [props.tools]);
197+ const prefetch = usePrefetchToolDetail();
198+ const entries = [...tree.children.values()].sort((left, right) =>
199+ left.segment.localeCompare(right.segment)
200+ );
201+
202+ return (
203+ <div className="flex flex-col gap-px">
204+ {entries.map((node) => (
205+ <SourceToolTreeNodeView
206+ key={node.segment}
207+ node={node}
208+ depth={0}
209+ selectedToolPath={props.selectedToolPath}
210+ onSelectTool={props.onSelectTool}
211+ search={props.search}
212+ defaultOpen={props.isFiltered}
213+ sourceId={props.sourceId}
214+ prefetch={prefetch}
215+ />
216+ ))}
217+ </div>
218+ );
219+};
220+
221+const SourceToolTreeNodeView = (props: {
222+ node: ToolTreeNode;
223+ depth: number;
224+ selectedToolPath: string | null;
225+ onSelectTool: (path: string) => void;
226+ search: string;
227+ defaultOpen: boolean;
228+ sourceId: string;
229+ prefetch: ReturnType<typeof usePrefetchToolDetail>;
230+}) => {
231+ const {
232+ node,
233+ depth,
234+ selectedToolPath,
235+ onSelectTool,
236+ search,
237+ defaultOpen,
238+ sourceId,
239+ prefetch,
240+ } = props;
241+ const hasChildren = node.children.size > 0;
242+ const isLeaf = !!node.tool && !hasChildren;
243+
244+ const hasSelectedDescendant = useMemo(() => {
245+ if (!selectedToolPath) {
246+ return false;
247+ }
248+
249+ const check = (candidate: ToolTreeNode): boolean => {
250+ if (candidate.tool?.path === selectedToolPath) {
251+ return true;
252+ }
253+
254+ for (const child of candidate.children.values()) {
255+ if (check(child)) {
256+ return true;
257+ }
258+ }
259+
260+ return false;
261+ };
262+
263+ return check(node);
264+ }, [node, selectedToolPath]);
265+
266+ const [open, setOpen] = useState(defaultOpen || hasSelectedDescendant);
267+
268+ useEffect(() => {
269+ if (defaultOpen || hasSelectedDescendant) {
270+ setOpen(true);
271+ }
272+ }, [defaultOpen, hasSelectedDescendant]);
273+
274+ if (isLeaf) {
275+ return (
276+ <SourceToolListItem
277+ tool={node.tool as SourceInspection["tools"][number]}
278+ active={node.tool?.path === selectedToolPath}
279+ onSelect={() => onSelectTool(node.tool!.path)}
280+ search={search}
281+ depth={depth}
282+ sourceId={sourceId}
283+ prefetch={prefetch}
284+ />
285+ );
286+ }
287+
288+ const paddingLeft = 8 + depth * 16;
289+ const sortedChildren = [...node.children.values()].sort((left, right) =>
290+ left.segment.localeCompare(right.segment)
291+ );
292+ const leafCount = countToolLeaves(node);
293+
294+ return (
295+ <div>
296+ {node.tool ? (
297+ <div className="flex items-center">
298+ <button
299+ type="button"
300+ onClick={() => setOpen((value) => !value)}
301+ className="shrink-0 rounded p-0.5 text-muted-foreground/30 hover:text-muted-foreground"
302+ style={{ marginLeft: paddingLeft }}
303+ >
304+ <IconChevron
305+ className={cn("shrink-0 transition-transform duration-150", open && "rotate-90")}
306+ style={{ width: 8, height: 8 }}
307+ />
308+ </button>
309+ <SourceToolListItem
310+ tool={node.tool as SourceInspection["tools"][number]}
311+ active={node.tool?.path === selectedToolPath}
312+ onSelect={() => onSelectTool(node.tool!.path)}
313+ search={search}
314+ depth={-1}
315+ className="flex-1 pl-1"
316+ sourceId={sourceId}
317+ prefetch={prefetch}
318+ />
319+ </div>
320+ ) : (
321+ <button
322+ type="button"
323+ onClick={() => setOpen((value) => !value)}
324+ className={cn(
325+ "group flex w-full items-center gap-1.5 rounded-md py-1 pr-2.5 text-[12px] transition-colors hover:bg-accent/40",
326+ open ? "text-foreground/80" : "text-muted-foreground/60",
327+ )}
328+ style={{ paddingLeft }}
329+ >
330+ <IconChevron
331+ className={cn(
332+ "shrink-0 text-muted-foreground/30 transition-transform duration-150",
333+ open && "rotate-90",
334+ )}
335+ style={{ width: 8, height: 8 }}
336+ />
337+ <IconFolder
338+ className={cn("shrink-0", open ? "text-primary/60" : "text-muted-foreground/30")}
339+ style={{ width: 12, height: 12 }}
340+ />
341+ <span className="flex-1 truncate text-left font-mono">
342+ {highlightMatch(node.segment, search)}
343+ </span>
344+ <span className="shrink-0 tabular-nums text-[10px] text-muted-foreground/25">
345+ {leafCount}
346+ </span>
347+ </button>
348+ )}
349+
350+ {open && hasChildren && (
351+ <div className="relative flex flex-col gap-px">
352+ <span
353+ className="absolute bottom-1 top-0 w-px bg-border/40"
354+ style={{ left: paddingLeft + 5 }}
355+ aria-hidden
356+ />
357+ {sortedChildren.map((child) => (
358+ <SourceToolTreeNodeView
359+ key={child.segment}
360+ node={child}
361+ depth={depth + 1}
362+ selectedToolPath={selectedToolPath}
363+ onSelectTool={onSelectTool}
364+ search={search}
365+ defaultOpen={defaultOpen}
366+ sourceId={sourceId}
367+ prefetch={prefetch}
368+ />
369+ ))}
370+ </div>
371+ )}
372+ </div>
373+ );
374+};
375+
376+const SourceToolListItem = (props: {
377+ tool: SourceInspection["tools"][number];
378+ active: boolean;
379+ onSelect: () => void;
380+ search: string;
381+ depth: number;
382+ className?: string;
383+ sourceId: string;
384+ prefetch: ReturnType<typeof usePrefetchToolDetail>;
385+}) => {
386+ const ref = useRef<HTMLButtonElement>(null);
387+ const paddingLeft = props.depth >= 0 ? 8 + props.depth * 16 + 8 : undefined;
388+
389+ useEffect(() => {
390+ if (props.active && ref.current) {
391+ ref.current.scrollIntoView({ block: "nearest" });
392+ }
393+ }, [props.active]);
394+
395+ const label = props.depth >= 0
396+ ? props.tool.path.split(".").pop() ?? props.tool.path
397+ : props.tool.path;
398+
399+ return (
400+ <button
401+ ref={ref}
402+ type="button"
403+ onMouseEnter={() => {
404+ props.prefetch(props.sourceId, props.tool.path);
405+ }}
406+ onClick={props.onSelect}
407+ className={cn(
408+ "group flex w-full items-center gap-2 rounded-md py-1.5 pr-2.5 text-left transition-colors",
409+ props.active
410+ ? "border-l-2 border-l-primary bg-primary/10 text-foreground"
411+ : "text-foreground/70 hover:bg-accent/50 hover:text-foreground",
412+ props.className,
413+ )}
414+ style={paddingLeft != null ? { paddingLeft } : undefined}
415+ >
416+ <IconTool className="size-3 shrink-0 text-muted-foreground/40" />
417+ <span className="flex-1 truncate font-mono text-[12px]">
418+ {highlightMatch(label, props.search)}
419+ </span>
420+ {props.tool.method && <MethodBadge method={props.tool.method} />}
421+ </button>
422+ );
423+};
424+
425+export const SourceToolDetailPanel = (props: SourceToolDetailPanelProps) => {
426+ const [copiedField, setCopiedField] = useState<string | null>(null);
427+ const inputType = props.detail.contract.input.typeDeclaration
428+ ?? props.detail.contract.input.typePreview
429+ ?? null;
430+ const outputType = props.detail.contract.output.typeDeclaration
431+ ?? props.detail.contract.output.typePreview
432+ ?? null;
433+
434+ return (
435+ <div className="flex h-full flex-col overflow-hidden">
436+ <div className="shrink-0 border-b border-border bg-background/95 backdrop-blur-sm">
437+ <div className="flex items-start gap-3 px-5 py-3.5">
438+ <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
439+ <IconTool className="size-4" />
440+ </div>
441+ <div className="min-w-0 flex-1">
442+ <div className="flex items-center gap-2">
443+ <h3 className="truncate text-sm font-semibold text-foreground">
444+ {props.detail.summary.path}
445+ </h3>
446+ <CopyButton
447+ text={props.detail.summary.path}
448+ field="path"
449+ copiedField={copiedField}
450+ onCopy={async (text, field) => {
451+ await navigator.clipboard.writeText(text);
452+ setCopiedField(field);
453+ window.setTimeout(() => setCopiedField(null), 1500);
454+ }}
455+ />
456+ </div>
457+ <div className="mt-1 flex flex-wrap items-center gap-1.5">
458+ {props.detail.summary.method && (
459+ <MethodBadge method={props.detail.summary.method} />
460+ )}
461+ {props.renderHeaderMeta?.(props.detail)}
462+ </div>
463+ </div>
464+ </div>
465+ </div>
466+
467+ <div className="flex-1 overflow-y-auto">
468+ <div className="space-y-4 px-5 py-4">
469+ {props.detail.summary.description && (
470+ <Markdown>{props.detail.summary.description}</Markdown>
471+ )}
472+
473+ <div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
474+ <DocumentPanel title="Input" body={inputType} lang="typescript" empty="No input." />
475+ <DocumentPanel title="Output" body={outputType} lang="typescript" empty="No output." />
476+ </div>
477+
478+ <DocumentPanel
479+ title="Call Signature"
480+ body={props.detail.contract.callSignature}
481+ lang="typescript"
482+ empty="No call signature."
483+ />
484+
485+ <div className="grid grid-cols-1 gap-2 xl:grid-cols-2">
486+ <DocumentPanel
487+ title="Input schema"
488+ body={props.detail.contract.input.schemaJson}
489+ empty="No input schema."
490+ compact
491+ />
492+ <DocumentPanel
493+ title="Output schema"
494+ body={props.detail.contract.output.schemaJson}
495+ empty="No output schema."
496+ compact
497+ />
498+ {props.renderSchemaExtras?.(props.detail)}
499+ </div>
500+
501+ {props.detail.sections.map((section, index) => (
502+ <section
503+ key={`${section.title}-${String(index)}`}
504+ className="overflow-hidden rounded-lg border border-border bg-card/60"
505+ >
506+ <div className="border-b border-border px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
507+ {section.title}
508+ </div>
509+ {section.kind === "facts" ? (
510+ <div className="grid gap-2 p-4">
511+ {section.items.map((item) => (
512+ <div key={`${section.title}-${item.label}`} className="text-sm">
513+ <span className="text-muted-foreground">{item.label}:</span>{" "}
514+ <span className={item.mono ? "font-mono text-xs text-foreground" : "text-foreground"}>
515+ {item.value}
516+ </span>
517+ </div>
518+ ))}
519+ </div>
520+ ) : section.kind === "markdown" ? (
521+ <div className="p-4">
522+ <Markdown>{section.body}</Markdown>
523+ </div>
524+ ) : (
525+ section.body ? (
526+ <CodeBlock
527+ code={section.body}
528+ lang={section.language}
529+ className="max-h-[32rem]"
530+ />
531+ ) : (
532+ <div className="flex items-center justify-center p-6 text-[13px] text-muted-foreground/40">
533+ No content.
534+ </div>
535+ )
536+ )}
537+ </section>
538+ ))}
539+ </div>
540+ </div>
541+ </div>
542+ );
543+};
544+
545+export const SourceToolModelWorkbench = (
546+ props: SourceToolModelWorkbenchProps,
547+) => {
548+ const [search, setSearch] = useState("");
549+ const searchRef = useRef<HTMLInputElement>(null);
550+ const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean);
551+
552+ const filteredTools = useMemo(
553+ () =>
554+ props.bundle.tools.filter((tool) => {
555+ if (terms.length === 0) {
556+ return true;
557+ }
558+
559+ const corpus = [
560+ tool.path,
561+ tool.method ?? "",
562+ tool.inputTypePreview ?? "",
563+ tool.outputTypePreview ?? "",
564+ ]
565+ .join(" ")
566+ .toLowerCase();
567+
568+ return terms.every((term) => corpus.includes(term));
569+ }),
570+ [props.bundle.tools, terms],
571+ );
572+
573+ useEffect(() => {
574+ const handler = (event: KeyboardEvent) => {
575+ if (event.key === "/" && document.activeElement?.tagName !== "INPUT") {
576+ event.preventDefault();
577+ searchRef.current?.focus();
578+ }
579+
580+ if (event.key === "Escape") {
581+ searchRef.current?.blur();
582+ if (search.length > 0) {
583+ setSearch("");
584+ }
585+ }
586+ };
587+
588+ document.addEventListener("keydown", handler);
589+ return () => document.removeEventListener("keydown", handler);
590+ }, [search]);
591+
592+ const renderDetail = props.renderDetail ?? ((detail: SourceInspectionToolDetail) => (
593+ <SourceToolDetailPanel detail={detail} />
594+ ));
595+
596+ return (
597+ <>
598+ <div className="flex w-72 shrink-0 flex-col border-r border-border bg-card/30 lg:w-80 xl:w-[22rem]">
599+ <div className="shrink-0 border-b border-border px-3 py-2">
600+ <div className="flex h-8 items-center gap-2 rounded-md border border-input bg-background px-2.5">
601+ <IconSearch className="size-3.5 shrink-0 text-muted-foreground/40" />
602+ <input
603+ ref={searchRef}
604+ value={search}
605+ onChange={(event) => setSearch(event.target.value)}
606+ placeholder={`Filter ${props.bundle.toolCount} tools...`}
607+ className="min-w-0 flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground/40"
608+ />
609+ {search.length > 0 ? (
610+ <button
611+ type="button"
612+ onClick={() => setSearch("")}
613+ className="shrink-0 rounded p-0.5 text-muted-foreground/40 hover:text-foreground"
614+ >
615+ <IconClose />
616+ </button>
617+ ) : (
618+ <kbd className="shrink-0 rounded border border-border bg-muted px-1 py-px text-[10px] leading-none text-muted-foreground/50">
619+ /
620+ </kbd>
621+ )}
622+ </div>
623+ </div>
624+
625+ <div className="flex-1 overflow-y-auto">
626+ {filteredTools.length === 0 ? (
627+ <div className="p-4 text-center text-[13px] text-muted-foreground/50">
628+ {terms.length > 0 ? "No tools match your filter" : "No tools available"}
629+ </div>
630+ ) : (
631+ <div className="p-1.5">
632+ <SourceToolTree
633+ tools={filteredTools}
634+ selectedToolPath={props.selectedToolPath}
635+ onSelectTool={props.onSelectTool}
636+ search={search}
637+ isFiltered={terms.length > 0}
638+ sourceId={props.sourceId}
639+ />
640+ </div>
641+ )}
642+ </div>
643+ </div>
644+
645+ <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
646+ <LoadableBlock loadable={props.detail} loading="Loading tool...">
647+ {(detail) =>
648+ detail ? (
649+ renderDetail(detail)
650+ ) : (
651+ <EmptyState
652+ title={props.bundle.toolCount > 0 ? "Select a tool" : "No tools available"}
653+ description={
654+ props.bundle.toolCount > 0
655+ ? "Choose from the list or press / to search."
656+ : undefined
657+ }
658+ />
659+ )
660+ }
661+ </LoadableBlock>
662+ </div>
663+ </>
664+ );
665+};
666+
667+export const SourceToolDiscoveryPanel = <
668+ TResult extends SourceToolDiscoveryResult = SourceToolDiscoveryResult,
669+>(
670+ props: SourceToolDiscoveryPanelProps<TResult>,
671+) => {
672+ const [draftQuery, setDraftQuery] = useState(props.initialQuery);
673+
674+ useEffect(() => {
675+ setDraftQuery(props.initialQuery);
676+ }, [props.initialQuery]);
677+
678+ return (
679+ <div className="flex flex-1 flex-col overflow-hidden">
680+ <div className="shrink-0 border-b border-border px-4 py-3">
681+ <form
682+ className="flex max-w-2xl items-center gap-2"
683+ onSubmit={(event) => {
684+ event.preventDefault();
685+ props.onSubmitQuery(draftQuery.trim());
686+ }}
687+ >
688+ <div className="relative flex-1">
689+ <IconSearch className="absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/40" />
690+ <input
691+ value={draftQuery}
692+ onChange={(event) => setDraftQuery(event.target.value)}
693+ placeholder="Search tools..."
694+ className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/30"
695+ />
696+ </div>
697+ <button
698+ type="submit"
699+ className="inline-flex h-9 items-center rounded-md border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50"
700+ >
701+ Search
702+ </button>
703+ </form>
704+ </div>
705+
706+ <div className="flex-1 overflow-y-auto p-4">
707+ <LoadableBlock loadable={props.discovery} loading="Searching...">
708+ {(result) =>
709+ result.query.length === 0 ? (
710+ <EmptyState
711+ title="Search your tools"
712+ description="Type a query to find matching tools across this source."
713+ />
714+ ) : result.results.length === 0 ? (
715+ <EmptyState
716+ title="No results"
717+ description="Try different search terms."
718+ />
719+ ) : (
720+ <div className="max-w-3xl space-y-2">
721+ {result.results.map((item, index) => (
722+ <button
723+ key={item.path}
724+ type="button"
725+ onClick={() => props.onOpenTool(item.path)}
726+ className="group w-full rounded-lg border border-border bg-card/60 p-3.5 text-left transition-all hover:border-primary/30 hover:shadow-sm"
727+ >
728+ <div className="mb-1.5 flex items-center justify-between gap-3">
729+ <div className="flex min-w-0 items-center gap-2.5">
730+ <span className="flex size-5 shrink-0 items-center justify-center rounded-md bg-muted text-[10px] font-mono tabular-nums text-muted-foreground/60">
731+ {index + 1}
732+ </span>
733+ <h4 className="truncate font-mono text-[13px] font-medium text-foreground transition-colors group-hover:text-primary">
734+ {item.path}
735+ </h4>
736+ </div>
737+ <span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground/50">
738+ {item.score.toFixed(2)}
739+ </span>
740+ </div>
741+ {item.description && (
742+ <p className="line-clamp-2 text-[12px] leading-relaxed text-muted-foreground">
743+ {item.description}
744+ </p>
745+ )}
746+ </button>
747+ ))}
748+ </div>
749+ )
750+ }
751+ </LoadableBlock>
752+ </div>
753+ </div>
754+ );
755+};
packages/clients/react/src/plugins/index.ts+10−1
@@ -92,6 +92,15 @@ export { · 91 unmodified lines
9292 } from "./components/markdown";
9393 export {
9494 SourceToolExplorer,
95+} from "./components/source-tool-explorer";
96+export {
9597 type SourceToolExplorerSearch,
9698 parseSourceToolExplorerSearch,
97−} from "./components/source-tool-explorer";
99+} from "./components/source-tool-explorer-search";
100+export {
101+ SourceToolDetailPanel,
102+ SourceToolDiscoveryPanel,
103+ SourceToolModelWorkbench,
104+ type SourceToolDetailPanelProps,
105+ type SourceToolDiscoveryResult,
106+} from "./components/source-tool-workbench";
plugins/google-discovery/react/components.tsxrenamed from plugins/google-discovery/react/index.tsx+19−74
@@ -12,9 +12,9 @@ import { · 11 unmodified lines
1212 useSource,
1313 } from "@executor/react";
1414 import {
15+ Badge,
1516 IconPencil,
1617 SourceToolExplorer,
17− defineExecutorFrontendPlugin,
1818 parseSourceToolExplorerSearch,
1919 type SourceToolExplorerSearch,
2020 useSourcePluginNavigation,
@@ -38,48 +38,10 @@ import { · 17 unmodified lines
3838 type GoogleDiscoveryStartBatchOAuthInput,
3939 type GoogleDiscoveryStartOAuthInput,
4040 } from "@executor/plugin-google-discovery-shared";
41+import { getGoogleDiscoveryIconUrlForService } from "./icons";
4142
4243 const OAUTH_TIMEOUT_MS = 2 * 60_000;
4344
44−const GOOGLE_DISCOVERY_SERVICE_ICONS: Record<string, string> = {
45− admin: "https://ssl.gstatic.com/images/branding/product/2x/admin_2020q4_48dp.png",
46− bigquery: "https://ssl.gstatic.com/bqui1/favicon.ico",
47− calendar: "https://ssl.gstatic.com/calendar/images/dynamiclogo_2020q4/calendar_31_2x.png",
48− chat: "https://ssl.gstatic.com/chat/favicon/favicon_v2.ico",
49− classroom: "https://ssl.gstatic.com/classroom/favicon.png",
50− cloudresourcemanager: "https://www.gstatic.com/devrel-devsite/prod/v0e0f589edd85502a40d78d7d0825db8ea5ef3b99b1571571945f0f3f764ff61b/cloud/images/favicons/onecloud/favicon.ico",
51− docs: "https://ssl.gstatic.com/docs/documents/images/kix-favicon7.ico",
52− drive: "https://ssl.gstatic.com/images/branding/product/2x/drive_2020q4_48dp.png",
53− forms: "https://ssl.gstatic.com/docs/forms/device_home/android_192.png",
54− gmail: "https://ssl.gstatic.com/ui/v1/icons/mail/rfr/gmail.ico",
55− keep: "https://ssl.gstatic.com/keep/icon_2020q4v2_128.png",
56− people: "https://ssl.gstatic.com/images/branding/product/2x/contacts_2022_48dp.png",
57− script: "https://ssl.gstatic.com/script/images/favicon.ico",
58− searchconsole: "https://ssl.gstatic.com/search-console/scfe/search_console-64.png",
59− sheets: "https://ssl.gstatic.com/docs/spreadsheets/favicon3.ico",
60− slides: "https://ssl.gstatic.com/docs/presentations/images/favicon5.ico",
61− tasks: "https://ssl.gstatic.com/tasks/images/favicon.ico",
62− youtube: "https://www.youtube.com/s/desktop/a94e1818/img/favicon_32x32.png",
63−};
64−
65−const getGoogleDiscoveryServiceKey = (
66− source: Pick<Source, "namespace">,
67−): string | null => {
68− const namespace = source.namespace?.trim();
69− if (!namespace || !namespace.startsWith("google.")) {
70− return null;
71− }
72−
73− return namespace.slice("google.".length).replaceAll(".", "");
74−};
75−
76−export const getGoogleDiscoveryIconUrl = (
77− source: Pick<Source, "namespace">,
78−): string | null => {
79− const serviceKey = getGoogleDiscoveryServiceKey(source);
80− return serviceKey ? GOOGLE_DISCOVERY_SERVICE_ICONS[serviceKey] ?? null : null;
81−};
82−
8345 const getGoogleDiscoveryHttpClient =
8446 defineExecutorPluginHttpApiClient<"GoogleDiscoveryReactHttpClient">()(
8547 "GoogleDiscoveryReactHttpClient",
@@ -477,7 +439,14 @@ const openGoogleOauthPopup = (name: string): Window | null => { · 391 unmodified lines
477439 <main style="font-family: system-ui, -apple-system, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; color: #333;">
478440 <div style="width: 20px; height: 20px; border: 2px solid #e5e7eb; border-top-color: #6366f1; border-radius: 50%; animation: spin 0.7s linear infinite; margin-bottom: 16px;"></div>
479441 <p style="margin: 0; font-size: 14px; color: #888;">Redirecting to Google&hellip;</p>
480− <style>@keyframes spin { to { transform: rotate(360deg); } }</style>
442+ <style>
443+ @keyframes spin { to { transform: rotate(360deg); } }
444+ @media (prefers-color-scheme: dark) {
445+ main { background: #09090b; color: #fafafa !important; }
446+ p { color: #a1a1aa !important; }
447+ div { border-color: #3f3f46 !important; }
448+ }
449+ </style>
481450 </main>
482451 `;
483452 } catch {
@@ -1058,8 +1027,7 @@ function GoogleDiscoveryBatchConnectPanel(props: { · 574 unmodified lines
10581027 <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
10591028 {GOOGLE_DISCOVERY_BATCH_TEMPLATES.map((template) => {
10601029 const selected = selectedTemplateIds.includes(template.id);
1061− const serviceKey = template.service.replaceAll(".", "");
1062− const iconUrl = GOOGLE_DISCOVERY_SERVICE_ICONS[serviceKey] ?? null;
1030+ const iconUrl = getGoogleDiscoveryIconUrlForService(template.service);
10631031
10641032 return (
10651033 <button
@@ -1166,7 +1134,7 @@ function GoogleDiscoveryBatchConnectPanel(props: { · 100 unmodified lines
11661134 );
11671135 }
11681136
1169−function GoogleDiscoveryAddPage() {
1137+export function GoogleDiscoveryAddPage() {
11701138 const navigation = useSourcePluginNavigation();
11711139 const initialValue = googleInputFromSearch(useSourcePluginSearch());
11721140 const installation = useLocalInstallation();
@@ -1230,7 +1198,7 @@ function GoogleDiscoveryAddPage() { · 57 unmodified lines
12301198 );
12311199 }
12321200
1233−function GoogleDiscoveryEditPage(props: {
1201+export function GoogleDiscoveryEditPage(props: {
12341202 source: Source;
12351203 }) {
12361204 const navigation = useSourcePluginNavigation();
@@ -1303,7 +1271,7 @@ function GoogleDiscoveryEditPage(props: { · 66 unmodified lines
13031271 );
13041272 }
13051273
1306−function GoogleDiscoveryDetailPage(props: {
1274+export function GoogleDiscoveryDetailPage(props: {
13071275 source: Source;
13081276 }) {
13091277 const navigation = useSourcePluginNavigation();
@@ -1341,9 +1309,9 @@ function GoogleDiscoveryDetailPage(props: { · 31 unmodified lines
13411309
13421310 const config = configResult.value;
13431311 return (
1344− <div className="space-y-1 text-xs text-muted-foreground">
1345− <div className="text-foreground">{config.service} {config.version}</div>
1346− <div className="font-mono truncate">
1312+ <div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
1313+ <Badge variant="muted">{config.service} {config.version}</Badge>
1314+ <div className="font-mono text-foreground">
13471315 {config.discoveryUrl ?? defaultGoogleDiscoveryUrl(config.service, config.version)}
13481316 </div>
13491317 </div>
@@ -1476,7 +1444,7 @@ function GoogleDiscoverySourceRoute(props: { · 126 unmodified lines
14761444 return props.children(source.data);
14771445 }
14781446
1479−function GoogleDiscoveryEditRoute() {
1447+export function GoogleDiscoveryEditRoute() {
14801448 return (
14811449 <GoogleDiscoverySourceRoute>
14821450 {(source) => <GoogleDiscoveryEditPage source={source} />}
@@ -1484,33 +1452,10 @@ function GoogleDiscoveryEditRoute() { · 1 unmodified line
14841452 );
14851453 }
14861454
1487−function GoogleDiscoveryDetailRoute() {
1455+export function GoogleDiscoveryDetailRoute() {
14881456 return (
14891457 <GoogleDiscoverySourceRoute>
14901458 {(source) => <GoogleDiscoveryDetailPage source={source} />}
14911459 </GoogleDiscoverySourceRoute>
14921460 );
14931461 }
1494−
1495−export const GoogleDiscoveryReactPlugin = defineExecutorFrontendPlugin({
1496− key: GOOGLE_DISCOVERY_PLUGIN_KEY,
1497− displayName: "Google Discovery",
1498− description: "Connect Google Workspace and Cloud APIs via discovery documents.",
1499− routes: [
1500− {
1501− key: "add",
1502− path: "add",
1503− component: GoogleDiscoveryAddPage,
1504− },
1505− {
1506− key: "detail",
1507− path: "sources/$sourceId",
1508− component: GoogleDiscoveryDetailRoute,
1509− },
1510− {
1511− key: "edit",
1512− path: "sources/$sourceId/edit",
1513− component: GoogleDiscoveryEditRoute,
1514− },
1515− ],
1516−});
plugins/google-discovery/react/icons.ts+44−0
@@ -0,0 +1,44 @@
1+import type { Source } from "@executor/react";
2+
3+const GOOGLE_DISCOVERY_SERVICE_ICONS: Record<string, string> = {
4+ admin: "https://ssl.gstatic.com/images/branding/product/2x/admin_2020q4_48dp.png",
5+ bigquery: "https://ssl.gstatic.com/bqui1/favicon.ico",
6+ calendar: "https://ssl.gstatic.com/calendar/images/dynamiclogo_2020q4/calendar_31_2x.png",
7+ chat: "https://ssl.gstatic.com/chat/favicon/favicon_v2.ico",
8+ classroom: "https://ssl.gstatic.com/classroom/favicon.png",
9+ cloudresourcemanager: "https://www.gstatic.com/devrel-devsite/prod/v0e0f589edd85502a40d78d7d0825db8ea5ef3b99b1571571945f0f3f764ff61b/cloud/images/favicons/onecloud/favicon.ico",
10+ docs: "https://ssl.gstatic.com/docs/documents/images/kix-favicon7.ico",
11+ drive: "https://ssl.gstatic.com/images/branding/product/2x/drive_2020q4_48dp.png",
12+ forms: "https://ssl.gstatic.com/docs/forms/device_home/android_192.png",
13+ gmail: "https://ssl.gstatic.com/ui/v1/icons/mail/rfr/gmail.ico",
14+ keep: "https://ssl.gstatic.com/keep/icon_2020q4v2_128.png",
15+ people: "https://ssl.gstatic.com/images/branding/product/2x/contacts_2022_48dp.png",
16+ script: "https://ssl.gstatic.com/script/images/favicon.ico",
17+ searchconsole: "https://ssl.gstatic.com/search-console/scfe/search_console-64.png",
18+ sheets: "https://ssl.gstatic.com/docs/spreadsheets/favicon3.ico",
19+ slides: "https://ssl.gstatic.com/docs/presentations/images/favicon5.ico",
20+ tasks: "https://ssl.gstatic.com/tasks/images/favicon.ico",
21+ youtube: "https://www.youtube.com/s/desktop/a94e1818/img/favicon_32x32.png",
22+};
23+
24+const getGoogleDiscoveryServiceKey = (
25+ source: Pick<Source, "namespace">,
26+): string | null => {
27+ const namespace = source.namespace?.trim();
28+ if (!namespace || !namespace.startsWith("google.")) {
29+ return null;
30+ }
31+
32+ return namespace.slice("google.".length).replaceAll(".", "");
33+};
34+
35+export const getGoogleDiscoveryIconUrl = (
36+ source: Pick<Source, "namespace">,
37+): string | null => {
38+ const serviceKey = getGoogleDiscoveryServiceKey(source);
39+ return serviceKey ? GOOGLE_DISCOVERY_SERVICE_ICONS[serviceKey] ?? null : null;
40+};
41+
42+export const getGoogleDiscoveryIconUrlForService = (
43+ service: string,
44+): string | null => GOOGLE_DISCOVERY_SERVICE_ICONS[service.replaceAll(".", "")] ?? null;
plugins/google-discovery/react/index.ts+35−0
@@ -0,0 +1,35 @@
1+import { defineExecutorFrontendPlugin } from "@executor/react/plugins";
2+
3+import {
4+ GOOGLE_DISCOVERY_PLUGIN_KEY,
5+} from "@executor/plugin-google-discovery-shared";
6+
7+import {
8+ GoogleDiscoveryAddPage,
9+ GoogleDiscoveryDetailRoute,
10+ GoogleDiscoveryEditRoute,
11+} from "./components";
12+export { getGoogleDiscoveryIconUrl } from "./icons";
13+
14+export const GoogleDiscoveryReactPlugin = defineExecutorFrontendPlugin({
15+ key: GOOGLE_DISCOVERY_PLUGIN_KEY,
16+ displayName: "Google Discovery",
17+ description: "Connect Google Workspace and Cloud APIs via discovery documents.",
18+ routes: [
19+ {
20+ key: "add",
21+ path: "add",
22+ component: GoogleDiscoveryAddPage,
23+ },
24+ {
25+ key: "detail",
26+ path: "sources/$sourceId",
27+ component: GoogleDiscoveryDetailRoute,
28+ },
29+ {
30+ key: "edit",
31+ path: "sources/$sourceId/edit",
32+ component: GoogleDiscoveryEditRoute,
33+ },
34+ ],
35+});
plugins/google-discovery/react/package.json+4−2
@@ -3,10 +3,12 @@ · 2 unmodified lines
33 "private": true,
44 "type": "module",
55 "exports": {
6− ".": "./index.tsx"
6+ ".": "./index.ts"
77 },
88 "files": [
9− "index.tsx"
9+ "components.tsx",
10+ "icons.ts",
11+ "index.ts"
1012 ],
1113 "scripts": {
1214 "typecheck": "bunx tsc --noEmit -p tsconfig.json"
plugins/google-discovery/react/tsconfig.json+2−1
@@ -11,6 +11,7 @@ · 10 unmodified lines
1111 "types": ["bun-types", "node"]
1212 },
1313 "include": [
14− "index.tsx"
14+ "*.ts",
15+ "*.tsx"
1516 ]
1617 }
plugins/graphql/react/components.tsxrenamed from plugins/graphql/react/index.tsx+26−756
@@ -1,9 +1,6 @@
11 import { startTransition, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
22 import type {
3− Loadable,
43 Source,
5− SourceInspection,
6− SourceInspectionDiscoverResult,
74 SourceInspectionToolDetail,
85 } from "@executor/react";
96 import {
@@ -14,7 +11,6 @@ import { · 4 unmodified lines
1411 useCreateSecret,
1512 useExecutorMutation,
1613 useLocalInstallation,
17− usePrefetchToolDetail,
1814 useSecrets,
1915 useSource,
2016 useSourceDiscovery,
@@ -24,20 +20,12 @@ import { · 3 unmodified lines
2420 import {
2521 Badge,
2622 DocumentPanel,
27− EmptyState,
28− IconCheck,
29− IconChevron,
30− IconClose,
31− IconCopy,
32− IconFolder,
3323 IconPencil,
34− IconSearch,
35− IconTool,
3624 LoadableBlock,
37− Markdown,
38− MethodBadge,
25+ SourceToolDetailPanel,
26+ SourceToolDiscoveryPanel,
27+ SourceToolModelWorkbench,
3928 cn,
40− defineExecutorFrontendPlugin,
4129 parseSourceToolExplorerSearch,
4230 useSourcePluginRouteParams,
4331 type SourceToolExplorerSearch,
@@ -63,12 +51,6 @@ type GraphqlToolRouteParams = { · 19 unmodified lines
6351 toolPath?: string;
6452 };
6553
66−type GraphqlToolTreeNode = {
67− segment: string;
68− tool?: SourceInspectionToolDetail["summary"] | SourceInspection["tools"][number];
69− children: Map<string, GraphqlToolTreeNode>;
70−};
71−
7254 const defaultGraphqlInput = (): GraphqlConnectInput => ({
7355 name: "My GraphQL Source",
7456 endpoint: "https://example.com/graphql",
@@ -338,47 +320,6 @@ const readGraphqlToolPath = (params: GraphqlToolRouteParams): string | null => { · 263 unmodified lines
338320 }
339321 };
340322
341−const buildGraphqlToolTree = (
342− tools: SourceInspection["tools"],
343−): GraphqlToolTreeNode => {
344− const root: GraphqlToolTreeNode = {
345− segment: "",
346− children: new Map(),
347− };
348−
349− for (const tool of tools) {
350− const parts = tool.path.split(".");
351− let node = root;
352−
353− for (const part of parts) {
354− const existing = node.children.get(part);
355− if (existing) {
356− node = existing;
357− continue;
358− }
359−
360− const next: GraphqlToolTreeNode = {
361− segment: part,
362− children: new Map(),
363− };
364− node.children.set(part, next);
365− node = next;
366− }
367−
368− node.tool = tool;
369− }
370−
371− return root;
372−};
373−
374−const countGraphqlToolLeaves = (node: GraphqlToolTreeNode): number => {
375− let count = node.tool ? 1 : 0;
376− for (const child of node.children.values()) {
377− count += countGraphqlToolLeaves(child);
378− }
379− return count;
380−};
381−
382323 function GraphqlSourceForm(props: {
383324 initialValue: GraphqlConnectInput;
384325 mode: "create" | "edit";
@@ -524,7 +465,7 @@ function GraphqlSourceForm(props: { · 139 unmodified lines
524465 );
525466 }
526467
527−function GraphqlAddPage() {
468+export function GraphqlAddPage() {
528469 const navigation = useSourcePluginNavigation();
529470 const initialValue = graphqlInputFromSearch(useSourcePluginSearch());
530471 const installation = useLocalInstallation();
@@ -563,7 +504,7 @@ function GraphqlAddPage() { · 32 unmodified lines
563504 );
564505 }
565506
566−function GraphqlEditPage(props: {
507+export function GraphqlEditPage(props: {
567508 source: Source;
568509 }) {
569510 const navigation = useSourcePluginNavigation();
@@ -876,7 +817,7 @@ function GraphqlDetailExplorer(props: { · 306 unmodified lines
876817
877818 <div className="flex min-h-0 flex-1 overflow-hidden">
878819 {tab === "model" ? (
879− <GraphqlModelView
820+ <SourceToolModelWorkbench
880821 bundle={loadedInspection}
881822 detail={toolDetail}
882823 selectedToolPath={activeTool?.path ?? null}
@@ -887,9 +828,15 @@ function GraphqlDetailExplorer(props: { · 4 unmodified lines
887828 });
888829 }}
889830 sourceId={props.source.id}
831+ renderDetail={(detail) => (
832+ <SourceToolDetailPanel
833+ detail={detail}
834+ renderHeaderMeta={renderGraphqlToolHeaderMeta}
835+ />
836+ )}
890837 />
891838 ) : (
892− <GraphqlDiscoveryView
839+ <SourceToolDiscoveryPanel
893840 discovery={discovery}
894841 initialQuery={query}
895842 onOpenTool={(toolPath) => {
@@ -915,673 +862,24 @@ function GraphqlDetailExplorer(props: { · 19 unmodified lines
915862 );
916863 }
917864
918−function GraphqlModelView(props: {
919− bundle: SourceInspection;
920− detail: Loadable<SourceInspectionToolDetail | null>;
921− selectedToolPath: string | null;
922− onSelectTool: (toolPath: string) => void;
923− sourceId: string;
924−}) {
925− const [search, setSearch] = useState("");
926− const searchRef = useRef<HTMLInputElement>(null);
927− const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean);
928− const filteredTools = useMemo(
929− () =>
930− props.bundle.tools.filter((tool) => {
931− if (terms.length === 0) {
932− return true;
933− }
934−
935− const corpus = [
936− tool.path,
937− tool.method ?? "",
938− tool.inputTypePreview ?? "",
939− tool.outputTypePreview ?? "",
940− ]
941− .join(" ")
942− .toLowerCase();
943−
944− return terms.every((term) => corpus.includes(term));
945− }),
946− [props.bundle.tools, terms],
947− );
948−
949− useEffect(() => {
950− const handler = (event: KeyboardEvent) => {
951− if (event.key === "/" && document.activeElement?.tagName !== "INPUT") {
952− event.preventDefault();
953− searchRef.current?.focus();
954− }
955−
956− if (event.key === "Escape") {
957− searchRef.current?.blur();
958− if (search.length > 0) {
959− setSearch("");
960− }
961− }
962− };
963−
964− document.addEventListener("keydown", handler);
965− return () => document.removeEventListener("keydown", handler);
966− }, [search]);
967−
968− return (
969− <>
970− <div className="flex w-72 shrink-0 flex-col border-r border-border bg-card/30 lg:w-80 xl:w-[22rem]">
971− <div className="shrink-0 border-b border-border px-3 py-2">
972− <div className="flex h-8 items-center gap-2 rounded-md border border-input bg-background px-2.5">
973− <IconSearch className="size-3.5 shrink-0 text-muted-foreground/40" />
974− <input
975− ref={searchRef}
976− value={search}
977− onChange={(event) => setSearch(event.target.value)}
978− placeholder={`Filter ${props.bundle.toolCount} tools...`}
979− className="min-w-0 flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground/40"
980− />
981− {search.length > 0 ? (
982− <button
983− type="button"
984− onClick={() => setSearch("")}
985− className="shrink-0 rounded p-0.5 text-muted-foreground/40 hover:text-foreground"
986− >
987− <IconClose />
988− </button>
989− ) : (
990− <kbd className="shrink-0 rounded border border-border bg-muted px-1 py-px text-[10px] leading-none text-muted-foreground/50">
991− /
992− </kbd>
993− )}
994− </div>
995− </div>
996−
997− <div className="flex-1 overflow-y-auto">
998− {filteredTools.length === 0 ? (
999− <div className="p-4 text-center text-[13px] text-muted-foreground/50">
1000− {terms.length > 0 ? "No tools match your filter" : "No tools available"}
1001− </div>
1002− ) : (
1003− <div className="p-1.5">
1004− <GraphqlToolTree
1005− tools={filteredTools}
1006− selectedToolPath={props.selectedToolPath}
1007− onSelectTool={props.onSelectTool}
1008− search={search}
1009− isFiltered={terms.length > 0}
1010− sourceId={props.sourceId}
1011− />
1012− </div>
1013− )}
1014− </div>
1015− </div>
1016−
1017− <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
1018− <LoadableBlock loadable={props.detail} loading="Loading tool...">
1019− {(detail) =>
1020− detail ? (
1021− <GraphqlToolDetailPanel detail={detail} />
1022− ) : (
1023− <EmptyState
1024− title={props.bundle.toolCount > 0 ? "Select a tool" : "No tools available"}
1025− description={
1026− props.bundle.toolCount > 0
1027− ? "Choose from the list or press / to search."
1028− : undefined
1029− }
1030− />
1031− )
1032− }
1033− </LoadableBlock>
1034− </div>
1035− </>
1036− );
1037−}
1038−
1039−function GraphqlToolTree(props: {
1040− tools: SourceInspection["tools"];
1041− selectedToolPath: string | null;
1042− onSelectTool: (path: string) => void;
1043− search: string;
1044− isFiltered: boolean;
1045− sourceId: string;
1046−}) {
1047− const tree = useMemo(() => buildGraphqlToolTree(props.tools), [props.tools]);
1048− const prefetch = usePrefetchToolDetail();
1049− const entries = [...tree.children.values()].sort((left, right) =>
1050− left.segment.localeCompare(right.segment)
1051− );
1052−
1053− return (
1054− <div className="flex flex-col gap-px">
1055− {entries.map((node) => (
1056− <GraphqlToolTreeNodeView
1057− key={node.segment}
1058− node={node}
1059− depth={0}
1060− selectedToolPath={props.selectedToolPath}
1061− onSelectTool={props.onSelectTool}
1062− search={props.search}
1063− defaultOpen={props.isFiltered}
1064− sourceId={props.sourceId}
1065− prefetch={prefetch}
1066− />
1067− ))}
1068− </div>
1069− );
1070−}
1071−
1072−function GraphqlToolTreeNodeView(props: {
1073− node: GraphqlToolTreeNode;
1074− depth: number;
1075− selectedToolPath: string | null;
1076− onSelectTool: (path: string) => void;
1077− search: string;
1078− defaultOpen: boolean;
1079− sourceId: string;
1080− prefetch: ReturnType<typeof usePrefetchToolDetail>;
1081−}) {
1082− const {
1083− node,
1084− depth,
1085− selectedToolPath,
1086− onSelectTool,
1087− search,
1088− defaultOpen,
1089− sourceId,
1090− prefetch,
1091− } = props;
1092− const hasChildren = node.children.size > 0;
1093− const isLeaf = !!node.tool && !hasChildren;
1094−
1095− const hasSelectedDescendant = useMemo(() => {
1096− if (!selectedToolPath) {
1097− return false;
1098− }
1099−
1100− function check(candidate: GraphqlToolTreeNode): boolean {
1101− if (candidate.tool?.path === selectedToolPath) {
1102− return true;
1103− }
1104−
1105− for (const child of candidate.children.values()) {
1106− if (check(child)) {
1107− return true;
1108− }
1109− }
1110−
1111− return false;
1112− }
1113−
1114− return check(node);
1115− }, [node, selectedToolPath]);
1116−
1117− const [open, setOpen] = useState(defaultOpen || hasSelectedDescendant);
1118−
1119− useEffect(() => {
1120− if (defaultOpen || hasSelectedDescendant) {
1121− setOpen(true);
1122− }
1123− }, [defaultOpen, hasSelectedDescendant]);
1124−
1125− if (isLeaf) {
1126− return (
1127− <GraphqlToolListItem
1128− tool={node.tool as SourceInspection["tools"][number]}
1129− active={node.tool?.path === selectedToolPath}
1130− onSelect={() => onSelectTool(node.tool!.path)}
1131− search={search}
1132− depth={depth}
1133− sourceId={sourceId}
1134− prefetch={prefetch}
1135− />
1136− );
1137− }
1138−
1139− const paddingLeft = 8 + depth * 16;
1140− const sortedChildren = [...node.children.values()].sort((left, right) =>
1141− left.segment.localeCompare(right.segment)
1142− );
1143− const leafCount = countGraphqlToolLeaves(node);
1144−
1145− return (
1146− <div>
1147− {node.tool ? (
1148− <div className="flex items-center">
1149− <button
1150− type="button"
1151− onClick={() => setOpen((value) => !value)}
1152− className="shrink-0 rounded p-0.5 text-muted-foreground/30 hover:text-muted-foreground"
1153− style={{ marginLeft: paddingLeft }}
1154− >
1155− <IconChevron
1156− className={cn("shrink-0 transition-transform duration-150", open && "rotate-90")}
1157− style={{ width: 8, height: 8 }}
1158− />
1159− </button>
1160− <GraphqlToolListItem
1161− tool={node.tool as SourceInspection["tools"][number]}
1162− active={node.tool?.path === selectedToolPath}
1163− onSelect={() => onSelectTool(node.tool!.path)}
1164− search={search}
1165− depth={-1}
1166− className="flex-1 pl-1"
1167− sourceId={sourceId}
1168− prefetch={prefetch}
1169− />
1170− </div>
1171− ) : (
1172− <button
1173− type="button"
1174− onClick={() => setOpen((value) => !value)}
1175− className={cn(
1176− "group flex w-full items-center gap-1.5 rounded-md py-1 pr-2.5 text-[12px] transition-colors hover:bg-accent/40",
1177− open ? "text-foreground/80" : "text-muted-foreground/60",
1178− )}
1179− style={{ paddingLeft }}
1180− >
1181− <IconChevron
1182− className={cn(
1183− "shrink-0 text-muted-foreground/30 transition-transform duration-150",
1184− open && "rotate-90",
1185− )}
1186− style={{ width: 8, height: 8 }}
1187− />
1188− <IconFolder
1189− className={cn("shrink-0", open ? "text-primary/60" : "text-muted-foreground/30")}
1190− style={{ width: 12, height: 12 }}
1191− />
1192− <span className="flex-1 truncate text-left font-mono">
1193− {highlightMatch(node.segment, search)}
1194− </span>
1195− <span className="shrink-0 tabular-nums text-[10px] text-muted-foreground/25">
1196− {leafCount}
1197− </span>
1198− </button>
1199− )}
1200−
1201− {open && hasChildren && (
1202− <div className="relative flex flex-col gap-px">
1203− <span
1204− className="absolute bottom-1 top-0 w-px bg-border/40"
1205− style={{ left: paddingLeft + 5 }}
1206− aria-hidden
1207− />
1208− {sortedChildren.map((child) => (
1209− <GraphqlToolTreeNodeView
1210− key={child.segment}
1211− node={child}
1212− depth={depth + 1}
1213− selectedToolPath={selectedToolPath}
1214− onSelectTool={onSelectTool}
1215− search={search}
1216− defaultOpen={defaultOpen}
1217− sourceId={sourceId}
1218− prefetch={prefetch}
1219− />
1220− ))}
1221− </div>
1222− )}
1223− </div>
1224− );
1225−}
1226−
1227−function GraphqlToolListItem(props: {
1228− tool: SourceInspection["tools"][number];
1229− active: boolean;
1230− onSelect: () => void;
1231− search: string;
1232− depth: number;
1233− className?: string;
1234− sourceId: string;
1235− prefetch: ReturnType<typeof usePrefetchToolDetail>;
1236−}) {
1237− const ref = useRef<HTMLButtonElement>(null);
1238− const paddingLeft = props.depth >= 0 ? 8 + props.depth * 16 + 8 : undefined;
1239−
1240− useEffect(() => {
1241− if (props.active && ref.current) {
1242− ref.current.scrollIntoView({ block: "nearest" });
1243− }
1244− }, [props.active]);
1245−
1246− const label = props.depth >= 0
1247− ? props.tool.path.split(".").pop() ?? props.tool.path
1248− : props.tool.path;
1249−
1250− return (
1251− <button
1252− ref={ref}
1253− type="button"
1254− onMouseEnter={() => {
1255− props.prefetch(props.sourceId, props.tool.path);
1256− }}
1257− onClick={props.onSelect}
1258− className={cn(
1259− "group flex w-full items-center gap-2 rounded-md py-1.5 pr-2.5 text-left transition-colors",
1260− props.active
1261− ? "border-l-2 border-l-primary bg-primary/10 text-foreground"
1262− : "text-foreground/70 hover:bg-accent/50 hover:text-foreground",
1263− props.className,
1264− )}
1265− style={paddingLeft != null ? { paddingLeft } : undefined}
1266− >
1267− <IconTool className="size-3 shrink-0 text-muted-foreground/40" />
1268− <span className="flex-1 truncate font-mono text-[12px]">
1269− {highlightMatch(label, props.search)}
1270− </span>
1271− {props.tool.method && <MethodBadge method={props.tool.method} />}
1272− </button>
1273− );
1274−}
1275−
1276−function GraphqlToolDetailPanel(props: {
1277− detail: SourceInspectionToolDetail;
1278−}) {
1279− const [copiedField, setCopiedField] = useState<string | null>(null);
1280− const inputType = props.detail.contract.input.typeDeclaration
1281− ?? props.detail.contract.input.typePreview
1282− ?? null;
1283− const outputType = props.detail.contract.output.typeDeclaration
1284− ?? props.detail.contract.output.typePreview
1285− ?? null;
865+const renderGraphqlToolHeaderMeta = (detail: SourceInspectionToolDetail) => {
1286866 const detailGroup =
1287− props.detail.summary.group
1288− && props.detail.summary.group.toLowerCase() !== props.detail.summary.method?.toLowerCase()
1289− ? props.detail.summary.group
867+ detail.summary.group
868+ && detail.summary.group.toLowerCase() !== detail.summary.method?.toLowerCase()
869+ ? detail.summary.group
1290870 : null;
1291871
1292− return (
1293− <div className="flex h-full flex-col overflow-hidden">
1294− <div className="shrink-0 border-b border-border bg-background/95 backdrop-blur-sm">
1295− <div className="flex items-start gap-3 px-5 py-3.5">
1296− <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
1297− <IconTool className="size-4" />
1298− </div>
1299− <div className="min-w-0 flex-1">
1300− <div className="flex items-center gap-2">
1301− <h3 className="truncate text-sm font-semibold text-foreground">
1302− {props.detail.summary.path}
1303− </h3>
1304− <CopyButton
1305− text={props.detail.summary.path}
1306− field="path"
1307− copiedField={copiedField}
1308− onCopy={async (text, field) => {
1309− await navigator.clipboard.writeText(text);
1310− setCopiedField(field);
1311− window.setTimeout(() => setCopiedField(null), 1500);
1312− }}
1313− />
1314− </div>
1315− <div className="mt-1 flex flex-wrap items-center gap-1.5">
1316− {props.detail.summary.method && (
1317− <MethodBadge method={props.detail.summary.method} />
1318− )}
1319− {detailGroup && (
1320− <Badge variant="outline">{detailGroup}</Badge>
1321− )}
1322− {props.detail.summary.protocol && (
1323− <Badge variant="muted">{props.detail.summary.protocol}</Badge>
1324− )}
1325− </div>
1326− </div>
1327− </div>
1328− </div>
1329−
1330− <div className="flex-1 overflow-y-auto">
1331− <div className="space-y-4 px-5 py-4">
1332− {props.detail.summary.description && (
1333− <Markdown>{props.detail.summary.description}</Markdown>
1334− )}
1335−
1336− <div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
1337− <DocumentPanel title="Input" body={inputType} lang="typescript" empty="No input." />
1338− <DocumentPanel title="Output" body={outputType} lang="typescript" empty="No output." />
1339− </div>
1340−
1341− <DocumentPanel
1342− title="Call Signature"
1343− body={props.detail.contract.callSignature}
1344− lang="typescript"
1345− empty="No call signature."
1346− />
1347−
1348− <div className="grid grid-cols-1 gap-2 xl:grid-cols-2">
1349− <DocumentPanel
1350− title="Input schema"
1351− body={props.detail.contract.input.schemaJson}
1352− empty="No input schema."
1353− compact
1354− />
1355− <DocumentPanel
1356− title="Output schema"
1357− body={props.detail.contract.output.schemaJson}
1358− empty="No output schema."
1359− compact
1360− />
1361− </div>
1362−
1363− {props.detail.sections.map((section, index) => (
1364− <section
1365− key={`${section.title}-${String(index)}`}
1366− className="overflow-hidden rounded-lg border border-border bg-card/60"
1367− >
1368− <div className="border-b border-border px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
1369− {section.title}
1370− </div>
1371− {section.kind === "facts" ? (
1372− <div className="grid gap-2 p-4">
1373− {section.items.map((item) => (
1374− <div key={`${section.title}-${item.label}`} className="text-sm">
1375− <span className="text-muted-foreground">{item.label}:</span>{" "}
1376− <span className={item.mono ? "font-mono text-xs text-foreground" : "text-foreground"}>
1377− {item.value}
1378− </span>
1379− </div>
1380− ))}
1381− </div>
1382− ) : section.kind === "markdown" ? (
1383− <div className="p-4">
1384− <Markdown>{section.body}</Markdown>
1385− </div>
1386− ) : (
1387− <DocumentPanel
1388− title={section.title}
1389− body={section.body}
1390− lang={section.language}
1391− empty=""
1392− />
1393− )}
1394− </section>
1395− ))}
1396− </div>
1397− </div>
1398− </div>
1399− );
1400−}
1401−
1402−function GraphqlDiscoveryView(props: {
1403− discovery: Loadable<SourceInspectionDiscoverResult>;
1404− initialQuery: string;
1405− onSubmitQuery: (query: string) => void;
1406− onOpenTool: (toolPath: string) => void;
1407−}) {
1408− const [draftQuery, setDraftQuery] = useState(props.initialQuery);
1409−
1410− useEffect(() => {
1411− setDraftQuery(props.initialQuery);
1412− }, [props.initialQuery]);
1413−
1414− return (
1415− <div className="flex flex-1 flex-col overflow-hidden">
1416− <div className="shrink-0 border-b border-border px-4 py-3">
1417− <form
1418− className="flex max-w-2xl items-center gap-2"
1419− onSubmit={(event) => {
1420− event.preventDefault();
1421− props.onSubmitQuery(draftQuery.trim());
1422− }}
1423− >
1424− <div className="relative flex-1">
1425− <IconSearch className="absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/40" />
1426− <input
1427− value={draftQuery}
1428− onChange={(event) => setDraftQuery(event.target.value)}
1429− placeholder="Search tools..."
1430− className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/30"
1431− />
1432− </div>
1433− <button
1434− type="submit"
1435− className="inline-flex h-9 items-center rounded-md border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50"
1436− >
1437− Search
1438− </button>
1439− </form>
1440− </div>
1441−
1442− <div className="flex-1 overflow-y-auto p-4">
1443− <LoadableBlock loadable={props.discovery} loading="Searching...">
1444− {(result) =>
1445− result.query.length === 0 ? (
1446− <EmptyState
1447− title="Search your tools"
1448− description="Type a query to find matching tools across this source."
1449− />
1450− ) : result.results.length === 0 ? (
1451− <EmptyState
1452− title="No results"
1453− description="Try different search terms."
1454− />
1455− ) : (
1456− <div className="grid gap-3">
1457− {result.results.map((item, index) => (
1458− <button
1459− key={item.path}
1460− type="button"
1461− onClick={() => props.onOpenTool(item.path)}
1462− className="rounded-xl border border-border bg-card/60 p-4 text-left transition-colors hover:border-primary/30 hover:bg-card"
1463− >
1464− <div className="flex items-center justify-between gap-3">
1465− <div className="min-w-0">
1466− <div className="flex items-center gap-2">
1467− <span className="inline-flex size-5 items-center justify-center rounded-md bg-muted text-[10px] text-muted-foreground">
1468− {index + 1}
1469− </span>
1470− <span className="truncate font-mono text-xs text-foreground">
1471− {item.path}
1472− </span>
1473− </div>
1474− {item.description && (
1475− <div className="mt-2 text-sm text-muted-foreground">
1476− {item.description}
1477− </div>
1478− )}
1479− </div>
1480− <span className="shrink-0 font-mono text-[11px] text-muted-foreground/60">
1481− {item.score.toFixed(2)}
1482− </span>
1483− </div>
1484− </button>
1485− ))}
1486− </div>
1487− )
1488− }
1489− </LoadableBlock>
1490− </div>
1491− </div>
1492− );
1493−}
1494−
1495−function CopyButton(props: {
1496− text: string;
1497− field: string;
1498− copiedField: string | null;
1499− onCopy: (text: string, field: string) => void | Promise<void>;
1500−}) {
1501− return (
1502− <button
1503− type="button"
1504− onClick={() => {
1505− void props.onCopy(props.text, props.field);
1506− }}
1507− className="shrink-0 rounded p-1 text-muted-foreground/30 transition-colors hover:text-muted-foreground"
1508− title={`Copy ${props.field}`}
1509− >
1510− {props.copiedField === props.field ? <IconCheck /> : <IconCopy />}
1511− </button>
1512− );
1513−}
1514−
1515−function highlightMatch(text: string, search: string) {
1516− if (!search.trim()) {
1517− return text;
1518− }
1519−
1520− const terms = search.trim().toLowerCase().split(/\s+/);
1521− const lowerText = text.toLowerCase();
1522− const ranges: Array<[number, number]> = [];
1523−
1524− for (const term of terms) {
1525− let index = 0;
1526−
1527− while (index < lowerText.length) {
1528− const found = lowerText.indexOf(term, index);
1529− if (found === -1) {
1530− break;
1531− }
1532−
1533− ranges.push([found, found + term.length]);
1534− index = found + 1;
1535− }
1536− }
1537−
1538− if (ranges.length === 0) {
1539− return text;
1540− }
1541−
1542− ranges.sort((left, right) => left[0] - right[0]);
1543−
1544− const merged: Array<[number, number]> = [ranges[0]!];
1545− for (let index = 1; index < ranges.length; index++) {
1546− const last = merged[merged.length - 1]!;
1547− const current = ranges[index]!;
1548− if (current[0] <= last[1]) {
1549− last[1] = Math.max(last[1], current[1]);
1550− } else {
1551− merged.push(current);
1552− }
1553− }
1554−
1555− const parts: Array<{ text: string; highlighted: boolean }> = [];
1556− let cursor = 0;
1557− for (const [start, end] of merged) {
1558− if (cursor < start) {
1559− parts.push({ text: text.slice(cursor, start), highlighted: false });
1560− }
1561− parts.push({ text: text.slice(start, end), highlighted: true });
1562− cursor = end;
1563− }
1564−
1565− if (cursor < text.length) {
1566− parts.push({ text: text.slice(cursor), highlighted: false });
1567− }
1568−
1569872 return (
1570873 <>
1571− {parts.map((part, index) =>
1572− part.highlighted ? (
1573− <mark key={index} className="rounded-sm bg-primary/20 px-px text-foreground">
1574− {part.text}
1575− </mark>
1576− ) : (
1577− <span key={index}>{part.text}</span>
1578− ),
874+ {detailGroup && <Badge variant="outline">{detailGroup}</Badge>}
875+ {detail.summary.protocol && (
876+ <Badge variant="muted">{detail.summary.protocol}</Badge>
1579877 )}
1580878 </>
1581879 );
1582−}
880+};
1583881
1584−function GraphqlDetailPage(props: {
882+export function GraphqlDetailPage(props: {
1585883 source: Source;
1586884 }) {
1587885 return (
@@ -1592,7 +890,7 @@ function GraphqlDetailPage(props: { · 4 unmodified lines
1592890 );
1593891 }
1594892
1595−function GraphqlToolDetailPage(props: {
893+export function GraphqlToolDetailPage(props: {
1596894 source: Source;
1597895 }) {
1598896 const params = useSourcePluginRouteParams<GraphqlToolRouteParams>();
@@ -1639,7 +937,7 @@ function GraphqlSourceRoute(props: { · 40 unmodified lines
1639937 return props.children(source.data);
1640938 }
1641939
1642−function GraphqlDetailRoute() {
940+export function GraphqlDetailRoute() {
1643941 return (
1644942 <GraphqlSourceRoute>
1645943 {(source) => <GraphqlDetailPage source={source} />}
@@ -1647,7 +945,7 @@ function GraphqlDetailRoute() { · 1 unmodified line
1647945 );
1648946 }
1649947
1650−function GraphqlEditRoute() {
948+export function GraphqlEditRoute() {
1651949 return (
1652950 <GraphqlSourceRoute>
1653951 {(source) => <GraphqlEditPage source={source} />}
@@ -1655,38 +953,10 @@ function GraphqlEditRoute() { · 1 unmodified line
1655953 );
1656954 }
1657955
1658−function GraphqlToolDetailRoute() {
956+export function GraphqlToolDetailRoute() {
1659957 return (
1660958 <GraphqlSourceRoute>
1661959 {(source) => <GraphqlToolDetailPage source={source} />}
1662960 </GraphqlSourceRoute>
1663961 );
1664962 }
1665−
1666−export const GraphqlReactPlugin = defineExecutorFrontendPlugin({
1667− key: "graphql",
1668− displayName: "GraphQL",
1669− description: "Introspect a GraphQL endpoint into typed query and mutation tools.",
1670− routes: [
1671− {
1672− key: "add",
1673− path: "add",
1674− component: GraphqlAddPage,
1675− },
1676− {
1677− key: "detail",
1678− path: "sources/$sourceId",
1679− component: GraphqlDetailRoute,
1680− },
1681− {
1682− key: "edit",
1683− path: "sources/$sourceId/edit",
1684− component: GraphqlEditRoute,
1685− },
1686− {
1687− key: "tool-detail",
1688− path: "sources/$sourceId/tool/$toolPath",
1689− component: GraphqlToolDetailRoute,
1690− },
1691− ],
1692−});
plugins/graphql/react/index.ts+36−0
@@ -0,0 +1,36 @@
1+import { defineExecutorFrontendPlugin } from "@executor/react/plugins";
2+
3+import {
4+ GraphqlAddPage,
5+ GraphqlDetailRoute,
6+ GraphqlEditRoute,
7+ GraphqlToolDetailRoute,
8+} from "./components";
9+
10+export const GraphqlReactPlugin = defineExecutorFrontendPlugin({
11+ key: "graphql",
12+ displayName: "GraphQL",
13+ description: "Introspect a GraphQL endpoint into typed query and mutation tools.",
14+ routes: [
15+ {
16+ key: "add",
17+ path: "add",
18+ component: GraphqlAddPage,
19+ },
20+ {
21+ key: "detail",
22+ path: "sources/$sourceId",
23+ component: GraphqlDetailRoute,
24+ },
25+ {
26+ key: "edit",
27+ path: "sources/$sourceId/edit",
28+ component: GraphqlEditRoute,
29+ },
30+ {
31+ key: "tool-detail",
32+ path: "sources/$sourceId/tool/$toolPath",
33+ component: GraphqlToolDetailRoute,
34+ },
35+ ],
36+});
plugins/graphql/react/package.json+3−2
@@ -3,10 +3,11 @@ · 2 unmodified lines
33 "private": true,
44 "type": "module",
55 "exports": {
6− ".": "./index.tsx"
6+ ".": "./index.ts"
77 },
88 "files": [
9− "index.tsx"
9+ "components.tsx",
10+ "index.ts"
1011 ],
1112 "scripts": {
1213 "typecheck": "bunx tsc --noEmit -p tsconfig.json"
plugins/graphql/react/tsconfig.json+2−1
@@ -11,6 +11,7 @@ · 10 unmodified lines
1111 "types": ["bun-types", "node"]
1212 },
1313 "include": [
14− "index.tsx"
14+ "*.ts",
15+ "*.tsx"
1516 ]
1617 }
plugins/mcp/react/components.tsxrenamed from plugins/mcp/react/index.tsx+18−38
@@ -10,12 +10,12 @@ import { · 9 unmodified lines
1010 useSource,
1111 } from "@executor/react";
1212 import {
13+ Badge,
1314 IconCheck,
1415 IconPencil,
1516 IconSearch,
1617 IconSpinner,
1718 SourceToolExplorer,
18− defineExecutorFrontendPlugin,
1919 parseSourceToolExplorerSearch,
2020 type SourceToolExplorerSearch,
2121 useSourcePluginNavigation,
@@ -355,7 +355,14 @@ const openOauthPopup = (): Window | null => { · 333 unmodified lines
355355 <main style="font-family: system-ui, -apple-system, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; color: #333;">
356356 <div style="width: 20px; height: 20px; border: 2px solid #e5e7eb; border-top-color: #6366f1; border-radius: 50%; animation: spin 0.7s linear infinite; margin-bottom: 16px;"></div>
357357 <p style="margin: 0; font-size: 14px; color: #888;">Redirecting to sign in&hellip;</p>
358− <style>@keyframes spin { to { transform: rotate(360deg); } }</style>
358+ <style>
359+ @keyframes spin { to { transform: rotate(360deg); } }
360+ @media (prefers-color-scheme: dark) {
361+ main { background: #09090b; color: #fafafa !important; }
362+ p { color: #a1a1aa !important; }
363+ div { border-color: #3f3f46 !important; }
364+ }
365+ </style>
359366 </main>
360367 `;
361368 } catch {
@@ -973,7 +980,7 @@ function McpSourceForm(props: { · 611 unmodified lines
973980 );
974981 }
975982
976−function McpAddPage() {
983+export function McpAddPage() {
977984 const navigation = useSourcePluginNavigation();
978985 const initialValue = mcpInputFromSearch(useSourcePluginSearch());
979986 const installation = useLocalInstallation();
@@ -1012,7 +1019,7 @@ function McpAddPage() { · 32 unmodified lines
10121019 );
10131020 }
10141021
1015−function McpEditPage(props: {
1022+export function McpEditPage(props: {
10161023 source: Source;
10171024 }) {
10181025 const navigation = useSourcePluginNavigation();
@@ -1085,7 +1092,7 @@ function McpEditPage(props: { · 66 unmodified lines
10851092 );
10861093 }
10871094
1088−function McpDetailPage(props: {
1095+export function McpDetailPage(props: {
10891096 source: Source;
10901097 }) {
10911098 const navigation = useSourcePluginNavigation();
@@ -1127,14 +1134,10 @@ function McpDetailPage(props: { · 35 unmodified lines
11271134 : config.endpoint ?? "remote";
11281135
11291136 return (
1130− <div className="space-y-1">
1131− <div className="font-mono text-xs text-foreground">{location}</div>
1132− <div>
1133− Transport: <span className="text-foreground">{config.transport ?? "auto"}</span>
1134− </div>
1135− <div>
1136− Auth: <span className="text-foreground">{config.auth.kind}</span>
1137− </div>
1137+ <div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
1138+ <span className="font-mono text-foreground">{location}</span>
1139+ <Badge variant="muted">Transport: {config.transport ?? "auto"}</Badge>
1140+ <Badge variant="muted">Auth: {config.auth.kind}</Badge>
11381141 </div>
11391142 );
11401143 }, [configResult]);
@@ -1265,7 +1268,7 @@ function McpSourceRoute(props: { · 124 unmodified lines
12651268 return props.children(source.data);
12661269 }
12671270
1268−function McpEditRoute() {
1271+export function McpEditRoute() {
12691272 return (
12701273 <McpSourceRoute>
12711274 {(source) => <McpEditPage source={source} />}
@@ -1273,33 +1276,10 @@ function McpEditRoute() { · 1 unmodified line
12731276 );
12741277 }
12751278
1276−function McpDetailRoute() {
1279+export function McpDetailRoute() {
12771280 return (
12781281 <McpSourceRoute>
12791282 {(source) => <McpDetailPage source={source} />}
12801283 </McpSourceRoute>
12811284 );
12821285 }
1283−
1284−export const McpReactPlugin = defineExecutorFrontendPlugin({
1285− key: "mcp",
1286− displayName: "MCP",
1287− description: "Connect remote or local MCP servers.",
1288− routes: [
1289− {
1290− key: "add",
1291− path: "add",
1292− component: McpAddPage,
1293− },
1294− {
1295− key: "detail",
1296− path: "sources/$sourceId",
1297− component: McpDetailRoute,
1298− },
1299− {
1300− key: "edit",
1301− path: "sources/$sourceId/edit",
1302− component: McpEditRoute,
1303− },
1304− ],
1305−});
plugins/mcp/react/index.ts+30−0
@@ -0,0 +1,30 @@
1+import { defineExecutorFrontendPlugin } from "@executor/react/plugins";
2+
3+import {
4+ McpAddPage,
5+ McpDetailRoute,
6+ McpEditRoute,
7+} from "./components";
8+
9+export const McpReactPlugin = defineExecutorFrontendPlugin({
10+ key: "mcp",
11+ displayName: "MCP",
12+ description: "Connect remote or local MCP servers.",
13+ routes: [
14+ {
15+ key: "add",
16+ path: "add",
17+ component: McpAddPage,
18+ },
19+ {
20+ key: "detail",
21+ path: "sources/$sourceId",
22+ component: McpDetailRoute,
23+ },
24+ {
25+ key: "edit",
26+ path: "sources/$sourceId/edit",
27+ component: McpEditRoute,
28+ },
29+ ],
30+});
plugins/mcp/react/package.json+3−2
@@ -3,10 +3,11 @@ · 2 unmodified lines
33 "private": true,
44 "type": "module",
55 "exports": {
6− ".": "./index.tsx"
6+ ".": "./index.ts"
77 },
88 "files": [
9− "index.tsx"
9+ "components.tsx",
10+ "index.ts"
1011 ],
1112 "scripts": {
1213 "typecheck": "bunx tsc --noEmit -p tsconfig.json"
plugins/mcp/react/tsconfig.json+2−1
@@ -11,6 +11,7 @@ · 10 unmodified lines
1111 "types": ["bun-types", "node"]
1212 },
1313 "include": [
14− "index.tsx"
14+ "*.ts",
15+ "*.tsx"
1516 ]
1617 }
plugins/openapi/react/components.tsxrenamed from plugins/openapi/react/index.tsx+34−667
@@ -1,7 +1,5 @@
11 import type {
2− Loadable,
32 Source,
4− SourceInspection,
53 SourceInspectionToolDetail,
64 } from "@executor/react";
75 import {
@@ -11,7 +9,6 @@ import { · 3 unmodified lines
119 useAtomSet,
1210 useCreateSecret,
1311 useExecutorMutation,
14− usePrefetchToolDetail,
1512 useSecrets,
1613 useSource,
1714 useSourceDiscovery,
@@ -22,20 +19,12 @@ import { · 4 unmodified lines
2219 import {
2320 Badge,
2421 DocumentPanel,
25− EmptyState,
26− IconCheck,
27− IconChevron,
28− IconClose,
29− IconCopy,
30− IconFolder,
3122 IconPencil,
32− IconSearch,
33− IconTool,
3423 LoadableBlock,
35− Markdown,
36− MethodBadge,
24+ SourceToolDetailPanel,
25+ SourceToolDiscoveryPanel,
26+ SourceToolModelWorkbench,
3727 cn,
38− defineExecutorFrontendPlugin,
3928 parseSourceToolExplorerSearch,
4029 type SourceToolExplorerSearch,
4130 useSourcePluginNavigation,
@@ -168,49 +157,6 @@ const Section = (props: { · 126 unmodified lines
168157 </section>
169158 );
170159
171−type ToolTreeNode = {
172− segment: string;
173− tool?: SourceInspectionToolDetail["summary"] | SourceInspection["tools"][number];
174− children: Map<string, ToolTreeNode>;
175−};
176−
177−const buildToolTree = (tools: SourceInspection["tools"]): ToolTreeNode => {
178− const root: ToolTreeNode = {
179− segment: "",
180− children: new Map(),
181− };
182−
183− for (const tool of tools) {
184− const parts = tool.path.split(".");
185− let node = root;
186− for (const part of parts) {
187− const existing = node.children.get(part);
188− if (existing) {
189− node = existing;
190− continue;
191− }
192−
193− const next: ToolTreeNode = {
194− segment: part,
195− children: new Map(),
196− };
197− node.children.set(part, next);
198− node = next;
199− }
200− node.tool = tool;
201− }
202−
203− return root;
204−};
205−
206−const countToolLeaves = (node: ToolTreeNode): number => {
207− let count = node.tool ? 1 : 0;
208− for (const child of node.children.values()) {
209− count += countToolLeaves(child);
210− }
211− return count;
212−};
213−
214160 const isPreviewableSpecUrl = (value: string): boolean => {
215161 const trimmed = value.trim();
216162 if (!/^https?:\/\//i.test(trimmed)) {
@@ -740,7 +686,7 @@ function OpenApiSourceForm(props: { · 523 unmodified lines
740686 );
741687 }
742688
743−function OpenApiAddSourcePage() {
689+export function OpenApiAddSourcePage() {
744690 const navigation = useSourcePluginNavigation();
745691 const initialValue = openApiInputFromSearch(useSourcePluginSearch());
746692 const openApiHttpClient = getOpenApiHttpClient();
@@ -775,7 +721,7 @@ function OpenApiAddSourcePage() { · 28 unmodified lines
775721 );
776722 }
777723
778−function OpenApiEditSourcePage(props: {
724+export function OpenApiEditSourcePage(props: {
779725 source: Source;
780726 }) {
781727 const navigation = useSourcePluginNavigation();
@@ -848,7 +794,7 @@ function OpenApiEditSourcePage(props: { · 66 unmodified lines
848794 );
849795 }
850796
851−function OpenApiSourceDetailPage(props: {
797+export function OpenApiSourceDetailPage(props: {
852798 source: Source;
853799 }) {
854800 const navigation = useSourcePluginNavigation();
@@ -1041,7 +987,7 @@ function OpenApiSourceDetailPage(props: { · 186 unmodified lines
1041987
1042988 <div className="flex flex-1 min-h-0 overflow-hidden">
1043989 {tab === "model" ? (
1044− <ModelView
990+ <SourceToolModelWorkbench
1045991 bundle={loadedInspection}
1046992 detail={toolDetail}
1047993 selectedToolPath={selectedTool?.path ?? null}
@@ -1051,10 +997,16 @@ function OpenApiSourceDetailPage(props: { · 3 unmodified lines
1051997 tool: toolPath,
1052998 })}
1053999 sourceId={props.source.id}
1000+ renderDetail={(detail) => (
1001+ <SourceToolDetailPanel
1002+ detail={detail}
1003+ renderHeaderMeta={renderOpenApiToolHeaderMeta}
1004+ renderSchemaExtras={renderOpenApiToolSchemaExtras}
1005+ />
1006+ )}
10541007 />
10551008 ) : (
1056− <DiscoveryView
1057− bundle={loadedInspection}
1009+ <SourceToolDiscoveryPanel
10581010 initialQuery={query}
10591011 discovery={discovery}
10601012 onSubmitQuery={(nextQuery) =>
@@ -1078,586 +1030,23 @@ function OpenApiSourceDetailPage(props: { · 17 unmodified lines
10781030 );
10791031 }
10801032
1081−function ModelView(props: {
1082− bundle: SourceInspection;
1083− detail: Loadable<SourceInspectionToolDetail | null>;
1084− selectedToolPath: string | null;
1085− onSelectTool: (toolPath: string) => void;
1086− sourceId: string;
1087−}) {
1088− const [search, setSearch] = useState("");
1089− const searchRef = useRef<HTMLInputElement>(null);
1090− const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean);
1091−
1092− const filteredTools = props.bundle.tools.filter((tool) => {
1093− if (terms.length === 0) return true;
1094− const corpus = [
1095− tool.path,
1096− tool.method ?? "",
1097− tool.inputTypePreview ?? "",
1098− tool.outputTypePreview ?? "",
1099− ]
1100− .join(" ")
1101− .toLowerCase();
1102− return terms.every((term) => corpus.includes(term));
1103− });
1104−
1105− useEffect(() => {
1106− const handler = (event: KeyboardEvent) => {
1107− if (event.key === "/" && document.activeElement?.tagName !== "INPUT") {
1108− event.preventDefault();
1109− searchRef.current?.focus();
1110− }
1111− if (event.key === "Escape") {
1112− searchRef.current?.blur();
1113− if (search.length > 0) setSearch("");
1114− }
1115− };
1116− document.addEventListener("keydown", handler);
1117− return () => document.removeEventListener("keydown", handler);
1118− }, [search]);
1119−
1120− return (
1121− <>
1122− <div className="flex w-72 shrink-0 flex-col border-r border-border bg-card/30 lg:w-80 xl:w-[22rem]">
1123− <div className="shrink-0 border-b border-border px-3 py-2">
1124− <div className="flex h-8 items-center gap-2 rounded-md border border-input bg-background px-2.5">
1125− <IconSearch className="size-3.5 shrink-0 text-muted-foreground/40" />
1126− <input
1127− ref={searchRef}
1128− value={search}
1129− onChange={(event) => setSearch(event.target.value)}
1130− placeholder={`Filter ${props.bundle.toolCount} tools…`}
1131− className="min-w-0 flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground/40"
1132− />
1133− {search.length > 0 ? (
1134− <button
1135− type="button"
1136− onClick={() => setSearch("")}
1137− className="shrink-0 rounded p-0.5 text-muted-foreground/40 hover:text-foreground"
1138− >
1139− <IconClose />
1140− </button>
1141− ) : (
1142− <kbd className="shrink-0 rounded border border-border bg-muted px-1 py-px text-[10px] leading-none text-muted-foreground/50">
1143− /
1144− </kbd>
1145− )}
1146− </div>
1147− </div>
1148−
1149− <div className="flex-1 overflow-y-auto">
1150− {filteredTools.length === 0 ? (
1151− <div className="p-4 text-center text-[13px] text-muted-foreground/50">
1152− {terms.length > 0 ? "No tools match your filter" : "No tools available"}
1153− </div>
1154− ) : (
1155− <div className="p-1.5">
1156− <ToolTree
1157− tools={filteredTools}
1158− selectedToolPath={props.selectedToolPath}
1159− onSelectTool={props.onSelectTool}
1160− search={search}
1161− isFiltered={terms.length > 0}
1162− sourceId={props.sourceId}
1163− />
1164− </div>
1165− )}
1166− </div>
1167− </div>
1168−
1169− <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
1170− <LoadableBlock loadable={props.detail} loading="Loading tool...">
1171− {(detail) =>
1172− detail ? (
1173− <ToolDetailPanel detail={detail} />
1174− ) : (
1175− <EmptyState
1176− title={props.bundle.toolCount > 0 ? "Select a tool" : "No tools available"}
1177− description={props.bundle.toolCount > 0 ? "Choose from the list or press / to search" : undefined}
1178− />
1179− )
1180− }
1181− </LoadableBlock>
1182− </div>
1183− </>
1184− );
1185−}
1186−
1187−function ToolTree(props: {
1188− tools: SourceInspection["tools"];
1189− selectedToolPath: string | null;
1190− onSelectTool: (path: string) => void;
1191− search: string;
1192− isFiltered: boolean;
1193− sourceId: string;
1194−}) {
1195− const tree = useMemo(() => buildToolTree(props.tools), [props.tools]);
1196− const prefetch = usePrefetchToolDetail();
1197− const entries = [...tree.children.values()].sort((a, b) => a.segment.localeCompare(b.segment));
1198−
1199− return (
1200− <div className="flex flex-col gap-px">
1201− {entries.map((node) => (
1202− <ToolTreeNodeView
1203− key={node.segment}
1204− node={node}
1205− depth={0}
1206− selectedToolPath={props.selectedToolPath}
1207− onSelectTool={props.onSelectTool}
1208− search={props.search}
1209− defaultOpen={props.isFiltered}
1210− sourceId={props.sourceId}
1211− prefetch={prefetch}
1212− />
1213− ))}
1214− </div>
1215− );
1216−}
1217−
1218−function ToolTreeNodeView(props: {
1219− node: ToolTreeNode;
1220− depth: number;
1221− selectedToolPath: string | null;
1222− onSelectTool: (path: string) => void;
1223− search: string;
1224− defaultOpen: boolean;
1225− sourceId: string;
1226− prefetch: ReturnType<typeof usePrefetchToolDetail>;
1227−}) {
1228− const { node, depth, selectedToolPath, onSelectTool, search, defaultOpen, sourceId, prefetch } = props;
1229− const hasChildren = node.children.size > 0;
1230− const isLeaf = !!node.tool && !hasChildren;
1231−
1232− const hasSelectedDescendant = useMemo(() => {
1233− if (!selectedToolPath) return false;
1234− function check(candidate: ToolTreeNode): boolean {
1235− if (candidate.tool?.path === selectedToolPath) return true;
1236− for (const child of candidate.children.values()) {
1237− if (check(child)) return true;
1238− }
1239− return false;
1240− }
1241− return check(node);
1242− }, [node, selectedToolPath]);
1243−
1244− const [open, setOpen] = useState(defaultOpen || hasSelectedDescendant);
1245−
1246− useEffect(() => {
1247− if (defaultOpen || hasSelectedDescendant) setOpen(true);
1248− }, [defaultOpen, hasSelectedDescendant]);
1249−
1250− if (isLeaf) {
1251− return (
1252− <ToolListItem
1253− tool={node.tool as SourceInspection["tools"][number]}
1254− active={node.tool?.path === selectedToolPath}
1255− onSelect={() => onSelectTool(node.tool!.path)}
1256− search={search}
1257− depth={depth}
1258− sourceId={sourceId}
1259− prefetch={prefetch}
1260− />
1261− );
1262− }
1263−
1264− const paddingLeft = 8 + depth * 16;
1265− const sortedChildren = [...node.children.values()].sort((a, b) => a.segment.localeCompare(b.segment));
1266− const leafCount = countToolLeaves(node);
1267−
1268− return (
1269− <div>
1270− {node.tool ? (
1271− <div className="flex items-center">
1272− <button
1273− type="button"
1274− onClick={() => setOpen((value) => !value)}
1275− className="shrink-0 rounded p-0.5 text-muted-foreground/30 hover:text-muted-foreground"
1276− style={{ marginLeft: paddingLeft }}
1277− >
1278− <IconChevron
1279− className={cn("shrink-0 transition-transform duration-150", open && "rotate-90")}
1280− style={{ width: 8, height: 8 }}
1281− />
1282− </button>
1283− <ToolListItem
1284− tool={node.tool as SourceInspection["tools"][number]}
1285− active={node.tool?.path === selectedToolPath}
1286− onSelect={() => onSelectTool(node.tool!.path)}
1287− search={search}
1288− depth={-1}
1289− className="flex-1 pl-1"
1290− sourceId={sourceId}
1291− prefetch={prefetch}
1292− />
1293− </div>
1294− ) : (
1295− <button
1296− type="button"
1297− onClick={() => setOpen((value) => !value)}
1298− className={cn(
1299− "group flex w-full items-center gap-1.5 rounded-md py-1 pr-2.5 text-[12px] transition-colors hover:bg-accent/40",
1300− open ? "text-foreground/80" : "text-muted-foreground/60",
1301− )}
1302− style={{ paddingLeft }}
1303− >
1304− <IconChevron
1305− className={cn(
1306− "shrink-0 text-muted-foreground/30 transition-transform duration-150",
1307− open && "rotate-90",
1308− )}
1309− style={{ width: 8, height: 8 }}
1310− />
1311− <IconFolder
1312− className={cn("shrink-0", open ? "text-primary/60" : "text-muted-foreground/30")}
1313− style={{ width: 12, height: 12 }}
1314− />
1315− <span className="flex-1 truncate text-left font-mono">
1316− {highlightMatch(node.segment, search)}
1317− </span>
1318− <span className="shrink-0 tabular-nums text-[10px] text-muted-foreground/25">{leafCount}</span>
1319− </button>
1320− )}
1321−
1322− {open && hasChildren && (
1323− <div className="relative flex flex-col gap-px">
1324− <span className="absolute bottom-1 top-0 w-px bg-border/40" style={{ left: paddingLeft + 5 }} aria-hidden />
1325− {sortedChildren.map((child) => (
1326− <ToolTreeNodeView
1327− key={child.segment}
1328− node={child}
1329− depth={depth + 1}
1330− selectedToolPath={selectedToolPath}
1331− onSelectTool={onSelectTool}
1332− search={search}
1333− defaultOpen={defaultOpen}
1334− sourceId={sourceId}
1335− prefetch={prefetch}
1336− />
1337− ))}
1338− </div>
1339− )}
1340− </div>
1341− );
1342−}
1343−
1344−function ToolListItem(props: {
1345− tool: SourceInspection["tools"][number];
1346− active: boolean;
1347− onSelect: () => void;
1348− search: string;
1349− depth: number;
1350− className?: string;
1351− sourceId: string;
1352− prefetch: ReturnType<typeof usePrefetchToolDetail>;
1353−}) {
1354− const ref = useRef<HTMLButtonElement>(null);
1355− const paddingLeft = props.depth >= 0 ? 8 + props.depth * 16 + 8 : undefined;
1356−
1357− useEffect(() => {
1358− if (props.active && ref.current) {
1359− ref.current.scrollIntoView({ block: "nearest" });
1360− }
1361− }, [props.active]);
1362−
1363− const label = props.depth >= 0
1364− ? props.tool.path.split(".").pop() ?? props.tool.path
1365− : props.tool.path;
1366−
1367− return (
1368− <button
1369− ref={ref}
1370− type="button"
1371− onMouseEnter={() => {
1372− props.prefetch(props.sourceId, props.tool.path);
1373− }}
1374− onClick={props.onSelect}
1375− className={cn(
1376− "group flex w-full items-center gap-2 rounded-md py-1.5 pr-2.5 text-left transition-colors",
1377− props.active
1378− ? "border-l-2 border-l-primary bg-primary/10 text-foreground"
1379− : "text-foreground/70 hover:bg-accent/50 hover:text-foreground",
1380− props.className,
1381− )}
1382− style={paddingLeft != null ? { paddingLeft } : undefined}
1383− >
1384− <IconTool className="size-3 shrink-0 text-muted-foreground/40" />
1385− <span className="flex-1 truncate font-mono text-[12px]">
1386− {highlightMatch(label, props.search)}
1387− </span>
1388− {props.tool.method && <MethodBadge method={props.tool.method} />}
1389− </button>
1390− );
1391−}
1392−
1393−function ToolDetailPanel(props: {
1394− detail: SourceInspectionToolDetail;
1395−}) {
1396− const { detail } = props;
1397− const [copiedField, setCopiedField] = useState<string | null>(null);
1398− const inputType = detail.contract.input.typeDeclaration
1399− ?? detail.contract.input.typePreview
1400− ?? null;
1401− const outputType = detail.contract.output.typeDeclaration
1402− ?? detail.contract.output.typePreview
1403− ?? null;
1404−
1405− return (
1406− <div className="flex h-full flex-col overflow-hidden">
1407− <div className="shrink-0 border-b border-border bg-background/95 backdrop-blur-sm">
1408− <div className="flex items-start gap-3 px-5 py-3.5">
1409− <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
1410− <IconTool className="size-4" />
1411− </div>
1412− <div className="min-w-0 flex-1">
1413− <div className="flex items-center gap-2">
1414− <h3 className="truncate text-sm font-semibold text-foreground">
1415− {detail.summary.path}
1416− </h3>
1417− <CopyButton
1418− text={detail.summary.path}
1419− field="path"
1420− copiedField={copiedField}
1421− onCopy={async (text, field) => {
1422− await navigator.clipboard.writeText(text);
1423− setCopiedField(field);
1424− window.setTimeout(() => setCopiedField(null), 1500);
1425− }}
1426− />
1427− </div>
1428− <div className="mt-1 flex flex-wrap items-center gap-1.5">
1429− {detail.summary.method && <MethodBadge method={detail.summary.method} />}
1430− {detail.summary.pathTemplate && (
1431− <span className="font-mono text-[11px] text-muted-foreground/60">
1432− {detail.summary.pathTemplate}
1433− </span>
1434− )}
1435− </div>
1436− </div>
1437− </div>
1438− </div>
1439−
1440− <div className="flex-1 overflow-y-auto">
1441− <div className="space-y-4 px-5 py-4">
1442− {detail.summary.description && <Markdown>{detail.summary.description}</Markdown>}
1443−
1444− <div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
1445− <DocumentPanel title="Input" body={inputType} lang="typescript" empty="No input." />
1446− <DocumentPanel title="Output" body={outputType} lang="typescript" empty="No output." />
1447− </div>
1448−
1449− <DocumentPanel title="Call Signature" body={detail.contract.callSignature} lang="typescript" empty="No call signature." />
1450−
1451− <div className="grid grid-cols-1 gap-2 xl:grid-cols-2">
1452− <DocumentPanel title="Input schema" body={detail.contract.input.schemaJson} empty="No input schema." compact />
1453− <DocumentPanel title="Output schema" body={detail.contract.output.schemaJson} empty="No output schema." compact />
1454− {detail.contract.input.exampleJson && (
1455− <DocumentPanel title="Example request" body={detail.contract.input.exampleJson} empty="" compact />
1456− )}
1457− {detail.contract.output.exampleJson && (
1458− <DocumentPanel title="Example response" body={detail.contract.output.exampleJson} empty="" compact />
1459− )}
1460− </div>
1461−
1462− {detail.sections.map((section, index) => (
1463− <section
1464− key={`${section.title}-${String(index)}`}
1465− className="overflow-hidden rounded-lg border border-border bg-card/60"
1466− >
1467− <div className="border-b border-border px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
1468− {section.title}
1469− </div>
1470− {section.kind === "facts" ? (
1471− <div className="grid gap-2 p-4">
1472− {section.items.map((item) => (
1473− <div key={`${section.title}-${item.label}`} className="text-sm">
1474− <span className="text-muted-foreground">{item.label}:</span>{" "}
1475− <span className={item.mono ? "font-mono text-xs text-foreground" : "text-foreground"}>
1476− {item.value}
1477− </span>
1478− </div>
1479− ))}
1480− </div>
1481− ) : section.kind === "markdown" ? (
1482− <div className="p-4">
1483− <Markdown>{section.body}</Markdown>
1484− </div>
1485− ) : (
1486− <DocumentPanel title={section.title} body={section.body} lang={section.language} empty="" />
1487− )}
1488− </section>
1489− ))}
1490− </div>
1491− </div>
1492− </div>
1493− );
1494−}
1495−
1496−function DiscoveryView(props: {
1497− bundle: SourceInspection;
1498− discovery: Loadable<ReturnType<typeof useSourceDiscovery> extends Loadable<infer T> ? T : never>;
1499− initialQuery: string;
1500− onSubmitQuery: (query: string) => void;
1501− onOpenTool: (toolPath: string) => void;
1502−}) {
1503− const [draftQuery, setDraftQuery] = useState(props.initialQuery);
1504−
1505− useEffect(() => {
1506− setDraftQuery(props.initialQuery);
1507− }, [props.initialQuery]);
1508−
1509− return (
1510− <div className="flex flex-1 flex-col overflow-hidden">
1511− <div className="shrink-0 border-b border-border px-4 py-3">
1512− <form
1513− className="flex max-w-2xl items-center gap-2"
1514− onSubmit={(event) => {
1515− event.preventDefault();
1516− props.onSubmitQuery(draftQuery.trim());
1517− }}
1518− >
1519− <div className="relative flex-1">
1520− <IconSearch className="absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/40" />
1521− <input
1522− value={draftQuery}
1523− onChange={(event) => setDraftQuery(event.target.value)}
1524− placeholder="Search tools…"
1525− className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/30"
1526− />
1527− </div>
1528− <button
1529− type="submit"
1530− className="inline-flex h-9 items-center rounded-md border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50"
1531− >
1532− Search
1533− </button>
1534− </form>
1535− </div>
1536−
1537− <div className="flex-1 overflow-y-auto p-4">
1538− <LoadableBlock loadable={props.discovery} loading="Searching…">
1539− {(result) =>
1540− result.query.length === 0 ? (
1541− <EmptyState
1542− title="Search your tools"
1543− description="Type a query to find matching tools across this source."
1544− />
1545− ) : result.results.length === 0 ? (
1546− <EmptyState
1547− title="No results"
1548− description="Try different search terms."
1549− />
1550− ) : (
1551− <div className="max-w-3xl space-y-2">
1552− {result.results.map((item, index) => (
1553− <button
1554− key={item.path}
1555− type="button"
1556− onClick={() => props.onOpenTool(item.path)}
1557− className="group w-full rounded-lg border border-border bg-card/60 p-3.5 text-left transition-all hover:border-primary/30 hover:shadow-sm"
1558− >
1559− <div className="mb-1.5 flex items-center justify-between gap-3">
1560− <div className="flex min-w-0 items-center gap-2.5">
1561− <span className="flex size-5 shrink-0 items-center justify-center rounded-md bg-muted text-[10px] font-mono tabular-nums text-muted-foreground/60">
1562− {index + 1}
1563− </span>
1564− <h4 className="truncate font-mono text-[13px] font-medium text-foreground transition-colors group-hover:text-primary">
1565− {item.path}
1566− </h4>
1567− </div>
1568− <span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground/50">
1569− {item.score.toFixed(2)}
1570− </span>
1571− </div>
1572− {item.description && (
1573− <p className="line-clamp-2 text-[12px] leading-relaxed text-muted-foreground">
1574− {item.description}
1575− </p>
1576− )}
1577− </button>
1578− ))}
1579− </div>
1580− )
1581− }
1582− </LoadableBlock>
1583− </div>
1584− </div>
1585− );
1586−}
1587−
1588−function CopyButton(props: {
1589− text: string;
1590− field: string;
1591− copiedField: string | null;
1592− onCopy: (text: string, field: string) => void | Promise<void>;
1593−}) {
1594− return (
1595− <button
1596− type="button"
1597− onClick={() => {
1598− void props.onCopy(props.text, props.field);
1599− }}
1600− className="shrink-0 rounded p-1 text-muted-foreground/30 transition-colors hover:text-muted-foreground"
1601− title={`Copy ${props.field}`}
1602− >
1603− {props.copiedField === props.field ? <IconCheck /> : <IconCopy />}
1604− </button>
1605− );
1606−}
1607−
1608−function highlightMatch(text: string, search: string) {
1609− if (!search.trim()) return text;
1610− const terms = search.trim().toLowerCase().split(/\s+/);
1611− const lowerText = text.toLowerCase();
1612− const ranges: Array<[number, number]> = [];
1613−
1614− for (const term of terms) {
1615− let idx = 0;
1616− while (idx < lowerText.length) {
1617− const found = lowerText.indexOf(term, idx);
1618− if (found === -1) break;
1619− ranges.push([found, found + term.length]);
1620− idx = found + 1;
1621− }
1622− }
1623−
1624− if (ranges.length === 0) return text;
1625−
1626− ranges.sort((a, b) => a[0] - b[0]);
1627− const merged: Array<[number, number]> = [ranges[0]!];
1628− for (let index = 1; index < ranges.length; index++) {
1629− const last = merged[merged.length - 1]!;
1630− const current = ranges[index]!;
1631− if (current[0] <= last[1]) {
1632− last[1] = Math.max(last[1], current[1]);
1633− } else {
1634− merged.push(current);
1635− }
1636− }
1637−
1638− const parts: Array<{ text: string; hl: boolean }> = [];
1639− let cursor = 0;
1640− for (const [start, end] of merged) {
1641− if (cursor < start) parts.push({ text: text.slice(cursor, start), hl: false });
1642− parts.push({ text: text.slice(start, end), hl: true });
1643− cursor = end;
1644− }
1645− if (cursor < text.length) parts.push({ text: text.slice(cursor), hl: false });
1646−
1647− return (
1648− <>
1649− {parts.map((part, index) =>
1650− part.hl ? (
1651− <mark key={index} className="rounded-sm bg-primary/20 px-px text-foreground">
1652− {part.text}
1653− </mark>
1654− ) : (
1655− <span key={index}>{part.text}</span>
1656− ),
1657− )}
1658− </>
1659− );
1660−}
1033+const renderOpenApiToolHeaderMeta = (detail: SourceInspectionToolDetail) =>
1034+ detail.summary.pathTemplate ? (
1035+ <span className="font-mono text-[11px] text-muted-foreground/60">
1036+ {detail.summary.pathTemplate}
1037+ </span>
1038+ ) : null;
1039+
1040+const renderOpenApiToolSchemaExtras = (detail: SourceInspectionToolDetail) => (
1041+ <>
1042+ {detail.contract.input.exampleJson && (
1043+ <DocumentPanel title="Example request" body={detail.contract.input.exampleJson} empty="" compact />
1044+ )}
1045+ {detail.contract.output.exampleJson && (
1046+ <DocumentPanel title="Example response" body={detail.contract.output.exampleJson} empty="" compact />
1047+ )}
1048+ </>
1049+);
16611050
16621051 function OpenApiSourceRoute(props: {
16631052 children: (source: Source) => ReactNode;
@@ -1693,7 +1082,7 @@ function OpenApiSourceRoute(props: { · 29 unmodified lines
16931082 return props.children(source.data);
16941083 }
16951084
1696−function OpenApiDetailRoute() {
1085+export function OpenApiDetailRoute() {
16971086 return (
16981087 <OpenApiSourceRoute>
16991088 {(source) => <OpenApiSourceDetailPage source={source} />}
@@ -1701,32 +1090,10 @@ function OpenApiDetailRoute() { · 1 unmodified line
17011090 );
17021091 }
17031092
1704−function OpenApiEditRoute() {
1093+export function OpenApiEditRoute() {
17051094 return (
17061095 <OpenApiSourceRoute>
17071096 {(source) => <OpenApiEditSourcePage source={source} />}
17081097 </OpenApiSourceRoute>
17091098 );
17101099 }
1711−
1712−export const OpenApiReactPlugin = defineExecutorFrontendPlugin({
1713− key: "openapi",
1714− displayName: "OpenAPI",
1715− routes: [
1716− {
1717− key: "add",
1718− path: "add",
1719− component: OpenApiAddSourcePage,
1720− },
1721− {
1722− key: "detail",
1723− path: "sources/$sourceId",
1724− component: OpenApiDetailRoute,
1725− },
1726− {
1727− key: "edit",
1728− path: "sources/$sourceId/edit",
1729− component: OpenApiEditRoute,
1730− },
1731− ],
1732−});
plugins/openapi/react/index.ts+29−0
@@ -0,0 +1,29 @@
1+import { defineExecutorFrontendPlugin } from "@executor/react/plugins";
2+
3+import {
4+ OpenApiAddSourcePage,
5+ OpenApiDetailRoute,
6+ OpenApiEditRoute,
7+} from "./components";
8+
9+export const OpenApiReactPlugin = defineExecutorFrontendPlugin({
10+ key: "openapi",
11+ displayName: "OpenAPI",
12+ routes: [
13+ {
14+ key: "add",
15+ path: "add",
16+ component: OpenApiAddSourcePage,
17+ },
18+ {
19+ key: "detail",
20+ path: "sources/$sourceId",
21+ component: OpenApiDetailRoute,
22+ },
23+ {
24+ key: "edit",
25+ path: "sources/$sourceId/edit",
26+ component: OpenApiEditRoute,
27+ },
28+ ],
29+});
plugins/openapi/react/package.json+3−2
@@ -3,10 +3,11 @@ · 2 unmodified lines
33 "private": true,
44 "type": "module",
55 "exports": {
6− ".": "./index.tsx"
6+ ".": "./index.ts"
77 },
88 "files": [
9− "index.tsx"
9+ "components.tsx",
10+ "index.ts"
1011 ],
1112 "scripts": {
1213 "typecheck": "bunx tsc --noEmit -p tsconfig.json"
plugins/openapi/react/tsconfig.json+2−1
@@ -11,6 +11,7 @@ · 10 unmodified lines
1111 "types": ["bun-types", "node"]
1212 },
1313 "include": [
14− "index.tsx"
14+ "*.ts",
15+ "*.tsx"
1516 ]
1617 }