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
23 .replace(/^git\+/, "")23 .replace(/^git\+/, "")
24 .replace(/\.git$/, "");24 .replace(/\.git$/, "");
25 25
26const webPackage = JSON.parse(
27 readFileSync(new URL("./package.json", import.meta.url), "utf8"),
28) as {
29 dependencies?: Record<string, string>;
30};
31
32const workspaceExecutorPackages = Object.keys(webPackage.dependencies ?? {}).filter(
33 (name) => name.startsWith("@executor/"),
34);
35
26export default defineConfig({36export default defineConfig({
27 root: "src",37 root: "src",
28 plugins: [38 plugins: [
@@ -47,7 +57,15 @@ export default defineConfig({ · 18 unmodified lines
47 outDir: "../dist",57 outDir: "../dist",
48 emptyOutDir: true,58 emptyOutDir: true,
49 },59 },
60 optimizeDeps: {
61 exclude: workspaceExecutorPackages,
62 },
50 server: {63 server: {
51 port: 8788,64 port: 8788,
65 watch: {
66 ignored: [
67 "!**/node_modules/@executor/**",
68 ],
69 },
52 },70 },
53});71});
packages/clients/react/src/plugins/components/source-tool-explorer-search.ts+24−0
@@ -0,0 +1,24 @@
1export type SourceToolExplorerSearch = {
2 tab: "model" | "discover";
3 tool?: string;
4 query?: string;
5};
6
7const sourceToolExplorerTabs = ["model", "discover"] as const;
8
9export 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 @@
1import { startTransition, useEffect, useState, type ReactNode } from "react";1import { startTransition, useEffect, type ReactNode } from "react";
2 2
3import type { Loadable } from "../../core/types";
4import type {
5 SourceInspection,
6 SourceInspectionToolDetail,
7} from "@executor/platform-sdk/schema";
8import {3import {
9 useSourceDiscovery,4 useSourceDiscovery,
10 useSourceInspection,5 useSourceInspection,
11 useSourceToolDetail,6 useSourceToolDetail,
12} from "../../hooks/sources";7} from "../../hooks/sources";
13import { Badge, MethodBadge } from "./badge";8import { Badge } from "./badge";
14import { DocumentPanel } from "./document-panel";9import { LoadableBlock } from "./loadable";
15import { EmptyState, LoadableBlock } from "./loadable";
16import { Markdown } from "./markdown";
17import {10import {
18 IconCheck,11 SourceToolDiscoveryPanel,
19 IconCopy,12 SourceToolModelWorkbench,
20 IconSearch,13} from "./source-tool-workbench";
21 IconTool,14import type { SourceToolExplorerSearch } from "./source-tool-explorer-search";
22} from "./icons";
23import { cn } from "../lib/cn";15import { cn } from "../lib/cn";
24 16
25export type SourceToolExplorerSearch = {
26 tab: "model" | "discover";
27 tool?: string;
28 query?: string;
29};
30
31const sourceToolExplorerTabs = ["model", "discover"] as const;
32
33export 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
50const 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
72const 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
168const 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
260export const SourceToolExplorer = (props: {17export const SourceToolExplorer = (props: {
261 sourceId: string;18 sourceId: string;
262 title: string;19 title: string;
263 kind: string;20 kind: string;
264 search: SourceToolExplorerSearch;21 search: SourceToolExplorerSearch;
265 modelLayout?: "split" | "detail";
266 navigate?: (search: {22 navigate?: (search: {
267 tab: "model" | "discover";23 tab: "model" | "discover";
268 tool?: string;24 tool?: string;
@@ -275,9 +31,12 @@ export const SourceToolExplorer = (props: { · 6 unmodified lines
275 const tab = props.search.tab === "discover" ? "discover" : "model";31 const tab = props.search.tab === "discover" ? "discover" : "model";
276 const toolPath = props.search.tool ?? null;32 const toolPath = props.search.tool ?? null;
277 const detail = useSourceToolDetail(props.sourceId, tab === "model" ? toolPath : null);33 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 });
279 const navigate = props.navigate;39 const navigate = props.navigate;
280 const modelLayout = props.modelLayout ?? "split";
281 40
282 useEffect(() => {41 useEffect(() => {
283 if (42 if (
@@ -310,206 +69,113 @@ export const SourceToolExplorer = (props: { · 26 unmodified lines
310 loadedInspection.tools.find((candidate) => candidate.path === toolPath)69 loadedInspection.tools.find((candidate) => candidate.path === toolPath)
311 ?? loadedInspection.tools[0]70 ?? loadedInspection.tools[0]
312 ?? null;71 ?? 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 });
329 72
330 return (73 return (
331 <div className="space-y-4">74 <div className="flex h-full flex-col overflow-hidden">
332 <div className="rounded-2xl border border-border bg-card px-5 py-4">75 <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background/95 px-4 backdrop-blur-sm">
333 <div className="flex items-start justify-between gap-4">76 <div className="flex min-w-0 items-center gap-3">
334 <div className="min-w-0">77 <h2 className="truncate text-sm font-semibold text-foreground">
335 <div className="flex flex-wrap items-center gap-2">78 {props.title}
336 <h2 className="truncate text-lg font-semibold text-foreground">79 </h2>
337 {props.title}80 <Badge variant="outline">{props.kind}</Badge>
338 </h2>81 </div>
339 <Badge variant="outline">{props.kind}</Badge>82 <div className="flex items-center gap-2">
340 <Badge variant="muted">83 <div className="flex items-center gap-0.5 rounded-lg bg-muted/50 p-0.5">
341 {loadedInspection.toolCount} tool{loadedInspection.toolCount === 1 ? "" : "s"}84 {(["model", "discover"] as const).map((tabId) => (
342 </Badge>85 <button
343 </div>86 key={tabId}
344 {props.summary && (87 type="button"
345 <div className="mt-3 text-sm text-muted-foreground">88 onClick={() => {
346 {props.summary}89 if (!navigate) {
347 </div>90 return;
348 )}91 }
349 </div>92
350 <div className="flex items-center gap-2">93 startTransition(() => {
351 <div className="inline-flex rounded-lg bg-muted p-0.5">94 void navigate({
352 {(["model", "discover"] as const).map((entry) => (95 tab: tabId,
353 <button96 tool:
354 key={entry}97 tabId === "model"
355 type="button"98 ? (selectedTool?.path ?? undefined)
356 onClick={() => {99 : props.search.tool,
357 if (!navigate) {100 query: props.search.query,
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 });
367 });101 });
368 }}102 });
369 className={cn(103 }}
370 "rounded-md px-3 py-1 text-xs font-medium transition-colors",104 className={cn(
371 tab === entry105 "rounded-md px-3 py-1 text-[12px] font-medium transition-colors",
372 ? "bg-background text-foreground shadow-sm"106 tabId === tab
373 : "text-muted-foreground hover:text-foreground",107 ? "bg-background text-foreground shadow-sm"
374 )}108 : "text-muted-foreground hover:text-foreground",
375 >109 )}
376 {entry === "model" ? "Tools" : "Search"}110 >
377 </button>111 {tabId === "model" ? "Tools" : "Search"}
378 ))}112 </button>
379 </div>113 ))}
380 {props.actions}
381 </div>114 </div>
115 {props.actions}
382 </div>116 </div>
383 </div>117 </div>
384 118
385 {tab === "discover" ? (119 {props.summary && (
386 <DiscoveryPanel120 <div className="shrink-0 border-b border-border bg-card/30 px-4 py-2">
387 sourceId={props.sourceId}121 {props.summary}
388 query={props.search.query ?? ""}122 </div>
389 onQueryChange={(query) => {123 )}
390 if (!navigate) {
391 return;
392 }
393 124
394 startTransition(() => {125 <div className="flex min-h-0 flex-1 overflow-hidden">
395 void navigate({126 {tab === "model" ? (
396 tab: "discover",127 <SourceToolModelWorkbench
397 tool: props.search.tool,128 bundle={loadedInspection}
398 query,129 detail={detail}
399 });130 selectedToolPath={selectedTool?.path ?? null}
400 });131 onSelectTool={(nextToolPath) => {
401 }}132 if (!navigate) {
402 onOpenTool={(path) => {133 return;
403 if (!navigate) {134 }
404 return;
405 }
406 135
407 startTransition(() => {136 startTransition(() => {
408 void navigate({137 void navigate({
409 tab: "model",138 tab: "model",
410 tool: path,139 tool: nextToolPath,
411 query: props.search.query,140 query: props.search.query,
141 });
412 });142 });
413 });143 }}
414 }}144 sourceId={props.sourceId}
415 />145 />
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>
432 ) : (146 ) : (
433 <div className="flex min-h-0 flex-col gap-4 md:flex-row">147 <SourceToolDiscoveryPanel
434 <div148 discovery={discovery}
435 className={cn(149 initialQuery={props.search.query ?? ""}
436 "space-y-3 rounded-2xl border border-border bg-card p-4 md:w-72 md:shrink-0 xl:w-80",150 onSubmitQuery={(query) => {
437 selectedTool && "order-2 md:order-1",151 if (!navigate) {
438 )}152 return;
439 >153 }
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 }
464 154
465 startTransition(() => {155 startTransition(() => {
466 void navigate({156 void navigate({
467 tab: "model",157 tab: "discover",
468 tool: tool.path,158 tool: props.search.tool,
469 query: props.search.query,159 query,
470 });160 });
471 });161 });
472 }}162 }}
473 className={cn(163 onOpenTool={(nextToolPath) => {
474 "flex w-full items-center gap-2 rounded-lg border px-3 py-2 text-left transition-colors",164 if (!navigate) {
475 selectedTool?.path === tool.path165 return;
476 ? "border-primary/30 bg-primary/8"166 }
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>
490 167
491 <div168 startTransition(() => {
492 className={cn(169 void navigate({
493 "min-w-0 flex-1 rounded-2xl border border-border bg-card p-4",170 tab: "model",
494 selectedTool && "order-1 md:order-2",171 tool: nextToolPath,
495 )}172 query: props.search.query,
496 >173 });
497 <LoadableBlock loadable={detail} loading="Loading tool...">174 });
498 {(loadedDetail) =>175 }}
499 loadedDetail ? (176 />
500 <ToolDetailPanel detail={loadedDetail} />177 )}
501 ) : (178 </div>
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 )}
513 </div>179 </div>
514 );180 );
515 }}181 }}
packages/clients/react/src/plugins/components/source-tool-workbench.tsx+755−0
@@ -0,0 +1,755 @@
1import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
3import type { Loadable } from "../../core/types";
4import {
5 usePrefetchToolDetail,
6} from "../../hooks/sources";
7import type {
8 SourceInspection,
9 SourceInspectionToolDetail,
10} from "@executor/platform-sdk/schema";
11import { Badge, MethodBadge } from "./badge";
12import { CodeBlock } from "./code-block";
13import { DocumentPanel } from "./document-panel";
14import { EmptyState, LoadableBlock } from "./loadable";
15import { Markdown } from "./markdown";
16import {
17 IconCheck,
18 IconChevron,
19 IconClose,
20 IconCopy,
21 IconFolder,
22 IconSearch,
23 IconTool,
24} from "./icons";
25import { cn } from "../lib/cn";
26
27type ToolTreeNode = {
28 segment: string;
29 tool?: SourceInspectionToolDetail["summary"] | SourceInspection["tools"][number];
30 children: Map<string, ToolTreeNode>;
31};
32
33export type SourceToolDiscoveryResult = {
34 query: string;
35 results: ReadonlyArray<{
36 path: string;
37 score: number;
38 description?: string | null;
39 }>;
40};
41
42export type SourceToolDetailPanelProps = {
43 detail: SourceInspectionToolDetail;
44 renderHeaderMeta?: (detail: SourceInspectionToolDetail) => ReactNode;
45 renderSchemaExtras?: (detail: SourceInspectionToolDetail) => ReactNode;
46};
47
48export 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
57export 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
66const 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
97const 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
105const 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
170const 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
188const 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
221const 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
376const 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
425export 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
545export 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
667export 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
92} from "./components/markdown";92} from "./components/markdown";
93export {93export {
94 SourceToolExplorer,94 SourceToolExplorer,
95} from "./components/source-tool-explorer";
96export {
95 type SourceToolExplorerSearch,97 type SourceToolExplorerSearch,
96 parseSourceToolExplorerSearch,98 parseSourceToolExplorerSearch,
97} from "./components/source-tool-explorer";99} from "./components/source-tool-explorer-search";
100export {
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
12 useSource,12 useSource,
13} from "@executor/react";13} from "@executor/react";
14import {14import {
15 Badge,
15 IconPencil,16 IconPencil,
16 SourceToolExplorer,17 SourceToolExplorer,
17 defineExecutorFrontendPlugin,
18 parseSourceToolExplorerSearch,18 parseSourceToolExplorerSearch,
19 type SourceToolExplorerSearch,19 type SourceToolExplorerSearch,
20 useSourcePluginNavigation,20 useSourcePluginNavigation,
@@ -38,48 +38,10 @@ import { · 17 unmodified lines
38 type GoogleDiscoveryStartBatchOAuthInput,38 type GoogleDiscoveryStartBatchOAuthInput,
39 type GoogleDiscoveryStartOAuthInput,39 type GoogleDiscoveryStartOAuthInput,
40} from "@executor/plugin-google-discovery-shared";40} from "@executor/plugin-google-discovery-shared";
41import { getGoogleDiscoveryIconUrlForService } from "./icons";
41 42
42const OAUTH_TIMEOUT_MS = 2 * 60_000;43const OAUTH_TIMEOUT_MS = 2 * 60_000;
43 44
44const 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
65const 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
76export 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
83const getGoogleDiscoveryHttpClient =45const getGoogleDiscoveryHttpClient =
84 defineExecutorPluginHttpApiClient<"GoogleDiscoveryReactHttpClient">()(46 defineExecutorPluginHttpApiClient<"GoogleDiscoveryReactHttpClient">()(
85 "GoogleDiscoveryReactHttpClient",47 "GoogleDiscoveryReactHttpClient",
@@ -477,7 +439,14 @@ const openGoogleOauthPopup = (name: string): Window | null => { · 391 unmodified lines
477 <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;">439 <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;">
478 <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>440 <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>
479 <p style="margin: 0; font-size: 14px; color: #888;">Redirecting to Google&hellip;</p>441 <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>
481 </main>450 </main>
482 `;451 `;
483 } catch {452 } catch {
@@ -1058,8 +1027,7 @@ function GoogleDiscoveryBatchConnectPanel(props: { · 574 unmodified lines
1058 <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">1027 <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
1059 {GOOGLE_DISCOVERY_BATCH_TEMPLATES.map((template) => {1028 {GOOGLE_DISCOVERY_BATCH_TEMPLATES.map((template) => {
1060 const selected = selectedTemplateIds.includes(template.id);1029 const selected = selectedTemplateIds.includes(template.id);
1061 const serviceKey = template.service.replaceAll(".", "");1030 const iconUrl = getGoogleDiscoveryIconUrlForService(template.service);
1062 const iconUrl = GOOGLE_DISCOVERY_SERVICE_ICONS[serviceKey] ?? null;
1063 1031
1064 return (1032 return (
1065 <button1033 <button
@@ -1166,7 +1134,7 @@ function GoogleDiscoveryBatchConnectPanel(props: { · 100 unmodified lines
1166 );1134 );
1167}1135}
1168 1136
1169function GoogleDiscoveryAddPage() {1137export function GoogleDiscoveryAddPage() {
1170 const navigation = useSourcePluginNavigation();1138 const navigation = useSourcePluginNavigation();
1171 const initialValue = googleInputFromSearch(useSourcePluginSearch());1139 const initialValue = googleInputFromSearch(useSourcePluginSearch());
1172 const installation = useLocalInstallation();1140 const installation = useLocalInstallation();
@@ -1230,7 +1198,7 @@ function GoogleDiscoveryAddPage() { · 57 unmodified lines
1230 );1198 );
1231}1199}
1232 1200
1233function GoogleDiscoveryEditPage(props: {1201export function GoogleDiscoveryEditPage(props: {
1234 source: Source;1202 source: Source;
1235}) {1203}) {
1236 const navigation = useSourcePluginNavigation();1204 const navigation = useSourcePluginNavigation();
@@ -1303,7 +1271,7 @@ function GoogleDiscoveryEditPage(props: { · 66 unmodified lines
1303 );1271 );
1304}1272}
1305 1273
1306function GoogleDiscoveryDetailPage(props: {1274export function GoogleDiscoveryDetailPage(props: {
1307 source: Source;1275 source: Source;
1308}) {1276}) {
1309 const navigation = useSourcePluginNavigation();1277 const navigation = useSourcePluginNavigation();
@@ -1341,9 +1309,9 @@ function GoogleDiscoveryDetailPage(props: { · 31 unmodified lines
1341 1309
1342 const config = configResult.value;1310 const config = configResult.value;
1343 return (1311 return (
1344 <div className="space-y-1 text-xs text-muted-foreground">1312 <div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
1345 <div className="text-foreground">{config.service} {config.version}</div>1313 <Badge variant="muted">{config.service} {config.version}</Badge>
1346 <div className="font-mono truncate">1314 <div className="font-mono text-foreground">
1347 {config.discoveryUrl ?? defaultGoogleDiscoveryUrl(config.service, config.version)}1315 {config.discoveryUrl ?? defaultGoogleDiscoveryUrl(config.service, config.version)}
1348 </div>1316 </div>
1349 </div>1317 </div>
@@ -1476,7 +1444,7 @@ function GoogleDiscoverySourceRoute(props: { · 126 unmodified lines
1476 return props.children(source.data);1444 return props.children(source.data);
1477}1445}
1478 1446
1479function GoogleDiscoveryEditRoute() {1447export function GoogleDiscoveryEditRoute() {
1480 return (1448 return (
1481 <GoogleDiscoverySourceRoute>1449 <GoogleDiscoverySourceRoute>
1482 {(source) => <GoogleDiscoveryEditPage source={source} />}1450 {(source) => <GoogleDiscoveryEditPage source={source} />}
@@ -1484,33 +1452,10 @@ function GoogleDiscoveryEditRoute() { · 1 unmodified line
1484 );1452 );
1485}1453}
1486 1454
1487function GoogleDiscoveryDetailRoute() {1455export function GoogleDiscoveryDetailRoute() {
1488 return (1456 return (
1489 <GoogleDiscoverySourceRoute>1457 <GoogleDiscoverySourceRoute>
1490 {(source) => <GoogleDiscoveryDetailPage source={source} />}1458 {(source) => <GoogleDiscoveryDetailPage source={source} />}
1491 </GoogleDiscoverySourceRoute>1459 </GoogleDiscoverySourceRoute>
1492 );1460 );
1493}1461}
1494
1495export 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 @@
1import type { Source } from "@executor/react";
2
3const 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
24const 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
35export 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
42export 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 @@
1import { defineExecutorFrontendPlugin } from "@executor/react/plugins";
2
3import {
4 GOOGLE_DISCOVERY_PLUGIN_KEY,
5} from "@executor/plugin-google-discovery-shared";
6
7import {
8 GoogleDiscoveryAddPage,
9 GoogleDiscoveryDetailRoute,
10 GoogleDiscoveryEditRoute,
11} from "./components";
12export { getGoogleDiscoveryIconUrl } from "./icons";
13
14export 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
3 "private": true,3 "private": true,
4 "type": "module",4 "type": "module",
5 "exports": {5 "exports": {
6 ".": "./index.tsx"6 ".": "./index.ts"
7 },7 },
8 "files": [8 "files": [
9 "index.tsx"9 "components.tsx",
10 "icons.ts",
11 "index.ts"
10 ],12 ],
11 "scripts": {13 "scripts": {
12 "typecheck": "bunx tsc --noEmit -p tsconfig.json"14 "typecheck": "bunx tsc --noEmit -p tsconfig.json"
plugins/google-discovery/react/tsconfig.json+2−1
@@ -11,6 +11,7 @@ · 10 unmodified lines
11 "types": ["bun-types", "node"]11 "types": ["bun-types", "node"]
12 },12 },
13 "include": [13 "include": [
14 "index.tsx"14 "*.ts",
15 "*.tsx"
15 ]16 ]
16}17}
plugins/graphql/react/components.tsxrenamed from plugins/graphql/react/index.tsx+26−756
@@ -1,9 +1,6 @@
1import { startTransition, useEffect, useMemo, useRef, useState, type ReactNode } from "react";1import { startTransition, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2import type {2import type {
3 Loadable,
4 Source,3 Source,
5 SourceInspection,
6 SourceInspectionDiscoverResult,
7 SourceInspectionToolDetail,4 SourceInspectionToolDetail,
8} from "@executor/react";5} from "@executor/react";
9import {6import {
@@ -14,7 +11,6 @@ import { · 4 unmodified lines
14 useCreateSecret,11 useCreateSecret,
15 useExecutorMutation,12 useExecutorMutation,
16 useLocalInstallation,13 useLocalInstallation,
17 usePrefetchToolDetail,
18 useSecrets,14 useSecrets,
19 useSource,15 useSource,
20 useSourceDiscovery,16 useSourceDiscovery,
@@ -24,20 +20,12 @@ import { · 3 unmodified lines
24import {20import {
25 Badge,21 Badge,
26 DocumentPanel,22 DocumentPanel,
27 EmptyState,
28 IconCheck,
29 IconChevron,
30 IconClose,
31 IconCopy,
32 IconFolder,
33 IconPencil,23 IconPencil,
34 IconSearch,
35 IconTool,
36 LoadableBlock,24 LoadableBlock,
37 Markdown,25 SourceToolDetailPanel,
38 MethodBadge,26 SourceToolDiscoveryPanel,
27 SourceToolModelWorkbench,
39 cn,28 cn,
40 defineExecutorFrontendPlugin,
41 parseSourceToolExplorerSearch,29 parseSourceToolExplorerSearch,
42 useSourcePluginRouteParams,30 useSourcePluginRouteParams,
43 type SourceToolExplorerSearch,31 type SourceToolExplorerSearch,
@@ -63,12 +51,6 @@ type GraphqlToolRouteParams = { · 19 unmodified lines
63 toolPath?: string;51 toolPath?: string;
64};52};
65 53
66type GraphqlToolTreeNode = {
67 segment: string;
68 tool?: SourceInspectionToolDetail["summary"] | SourceInspection["tools"][number];
69 children: Map<string, GraphqlToolTreeNode>;
70};
71
72const defaultGraphqlInput = (): GraphqlConnectInput => ({54const defaultGraphqlInput = (): GraphqlConnectInput => ({
73 name: "My GraphQL Source",55 name: "My GraphQL Source",
74 endpoint: "https://example.com/graphql",56 endpoint: "https://example.com/graphql",
@@ -338,47 +320,6 @@ const readGraphqlToolPath = (params: GraphqlToolRouteParams): string | null => { · 263 unmodified lines
338 }320 }
339};321};
340 322
341const 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
374const 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
382function GraphqlSourceForm(props: {323function GraphqlSourceForm(props: {
383 initialValue: GraphqlConnectInput;324 initialValue: GraphqlConnectInput;
384 mode: "create" | "edit";325 mode: "create" | "edit";
@@ -524,7 +465,7 @@ function GraphqlSourceForm(props: { · 139 unmodified lines
524 );465 );
525}466}
526 467
527function GraphqlAddPage() {468export function GraphqlAddPage() {
528 const navigation = useSourcePluginNavigation();469 const navigation = useSourcePluginNavigation();
529 const initialValue = graphqlInputFromSearch(useSourcePluginSearch());470 const initialValue = graphqlInputFromSearch(useSourcePluginSearch());
530 const installation = useLocalInstallation();471 const installation = useLocalInstallation();
@@ -563,7 +504,7 @@ function GraphqlAddPage() { · 32 unmodified lines
563 );504 );
564}505}
565 506
566function GraphqlEditPage(props: {507export function GraphqlEditPage(props: {
567 source: Source;508 source: Source;
568}) {509}) {
569 const navigation = useSourcePluginNavigation();510 const navigation = useSourcePluginNavigation();
@@ -876,7 +817,7 @@ function GraphqlDetailExplorer(props: { · 306 unmodified lines
876 817
877 <div className="flex min-h-0 flex-1 overflow-hidden">818 <div className="flex min-h-0 flex-1 overflow-hidden">
878 {tab === "model" ? (819 {tab === "model" ? (
879 <GraphqlModelView820 <SourceToolModelWorkbench
880 bundle={loadedInspection}821 bundle={loadedInspection}
881 detail={toolDetail}822 detail={toolDetail}
882 selectedToolPath={activeTool?.path ?? null}823 selectedToolPath={activeTool?.path ?? null}
@@ -887,9 +828,15 @@ function GraphqlDetailExplorer(props: { · 4 unmodified lines
887 });828 });
888 }}829 }}
889 sourceId={props.source.id}830 sourceId={props.source.id}
831 renderDetail={(detail) => (
832 <SourceToolDetailPanel
833 detail={detail}
834 renderHeaderMeta={renderGraphqlToolHeaderMeta}
835 />
836 )}
890 />837 />
891 ) : (838 ) : (
892 <GraphqlDiscoveryView839 <SourceToolDiscoveryPanel
893 discovery={discovery}840 discovery={discovery}
894 initialQuery={query}841 initialQuery={query}
895 onOpenTool={(toolPath) => {842 onOpenTool={(toolPath) => {
@@ -915,673 +862,24 @@ function GraphqlDetailExplorer(props: { · 19 unmodified lines
915 );862 );
916}863}
917 864
918function GraphqlModelView(props: {865const renderGraphqlToolHeaderMeta = (detail: SourceInspectionToolDetail) => {
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
1039function 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
1072function 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
1227function 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
1276function 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;
1286 const detailGroup =866 const detailGroup =
1287 props.detail.summary.group867 detail.summary.group
1288 && props.detail.summary.group.toLowerCase() !== props.detail.summary.method?.toLowerCase()868 && detail.summary.group.toLowerCase() !== detail.summary.method?.toLowerCase()
1289 ? props.detail.summary.group869 ? detail.summary.group
1290 : null;870 : null;
1291 871
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
1402function 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
1495function 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
1515function 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
1569 return (872 return (
1570 <>873 <>
1571 {parts.map((part, index) =>874 {detailGroup && <Badge variant="outline">{detailGroup}</Badge>}
1572 part.highlighted ? (875 {detail.summary.protocol && (
1573 <mark key={index} className="rounded-sm bg-primary/20 px-px text-foreground">876 <Badge variant="muted">{detail.summary.protocol}</Badge>
1574 {part.text}
1575 </mark>
1576 ) : (
1577 <span key={index}>{part.text}</span>
1578 ),
1579 )}877 )}
1580 </>878 </>
1581 );879 );
1582}880};
1583 881
1584function GraphqlDetailPage(props: {882export function GraphqlDetailPage(props: {
1585 source: Source;883 source: Source;
1586}) {884}) {
1587 return (885 return (
@@ -1592,7 +890,7 @@ function GraphqlDetailPage(props: { · 4 unmodified lines
1592 );890 );
1593}891}
1594 892
1595function GraphqlToolDetailPage(props: {893export function GraphqlToolDetailPage(props: {
1596 source: Source;894 source: Source;
1597}) {895}) {
1598 const params = useSourcePluginRouteParams<GraphqlToolRouteParams>();896 const params = useSourcePluginRouteParams<GraphqlToolRouteParams>();
@@ -1639,7 +937,7 @@ function GraphqlSourceRoute(props: { · 40 unmodified lines
1639 return props.children(source.data);937 return props.children(source.data);
1640}938}
1641 939
1642function GraphqlDetailRoute() {940export function GraphqlDetailRoute() {
1643 return (941 return (
1644 <GraphqlSourceRoute>942 <GraphqlSourceRoute>
1645 {(source) => <GraphqlDetailPage source={source} />}943 {(source) => <GraphqlDetailPage source={source} />}
@@ -1647,7 +945,7 @@ function GraphqlDetailRoute() { · 1 unmodified line
1647 );945 );
1648}946}
1649 947
1650function GraphqlEditRoute() {948export function GraphqlEditRoute() {
1651 return (949 return (
1652 <GraphqlSourceRoute>950 <GraphqlSourceRoute>
1653 {(source) => <GraphqlEditPage source={source} />}951 {(source) => <GraphqlEditPage source={source} />}
@@ -1655,38 +953,10 @@ function GraphqlEditRoute() { · 1 unmodified line
1655 );953 );
1656}954}
1657 955
1658function GraphqlToolDetailRoute() {956export function GraphqlToolDetailRoute() {
1659 return (957 return (
1660 <GraphqlSourceRoute>958 <GraphqlSourceRoute>
1661 {(source) => <GraphqlToolDetailPage source={source} />}959 {(source) => <GraphqlToolDetailPage source={source} />}
1662 </GraphqlSourceRoute>960 </GraphqlSourceRoute>
1663 );961 );
1664}962}
1665
1666export 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 @@
1import { defineExecutorFrontendPlugin } from "@executor/react/plugins";
2
3import {
4 GraphqlAddPage,
5 GraphqlDetailRoute,
6 GraphqlEditRoute,
7 GraphqlToolDetailRoute,
8} from "./components";
9
10export 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
3 "private": true,3 "private": true,
4 "type": "module",4 "type": "module",
5 "exports": {5 "exports": {
6 ".": "./index.tsx"6 ".": "./index.ts"
7 },7 },
8 "files": [8 "files": [
9 "index.tsx"9 "components.tsx",
10 "index.ts"
10 ],11 ],
11 "scripts": {12 "scripts": {
12 "typecheck": "bunx tsc --noEmit -p tsconfig.json"13 "typecheck": "bunx tsc --noEmit -p tsconfig.json"
plugins/graphql/react/tsconfig.json+2−1
@@ -11,6 +11,7 @@ · 10 unmodified lines
11 "types": ["bun-types", "node"]11 "types": ["bun-types", "node"]
12 },12 },
13 "include": [13 "include": [
14 "index.tsx"14 "*.ts",
15 "*.tsx"
15 ]16 ]
16}17}
plugins/mcp/react/components.tsxrenamed from plugins/mcp/react/index.tsx+18−38
@@ -10,12 +10,12 @@ import { · 9 unmodified lines
10 useSource,10 useSource,
11} from "@executor/react";11} from "@executor/react";
12import {12import {
13 Badge,
13 IconCheck,14 IconCheck,
14 IconPencil,15 IconPencil,
15 IconSearch,16 IconSearch,
16 IconSpinner,17 IconSpinner,
17 SourceToolExplorer,18 SourceToolExplorer,
18 defineExecutorFrontendPlugin,
19 parseSourceToolExplorerSearch,19 parseSourceToolExplorerSearch,
20 type SourceToolExplorerSearch,20 type SourceToolExplorerSearch,
21 useSourcePluginNavigation,21 useSourcePluginNavigation,
@@ -355,7 +355,14 @@ const openOauthPopup = (): Window | null => { · 333 unmodified lines
355 <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;">355 <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;">
356 <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>356 <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>
357 <p style="margin: 0; font-size: 14px; color: #888;">Redirecting to sign in&hellip;</p>357 <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>
359 </main>366 </main>
360 `;367 `;
361 } catch {368 } catch {
@@ -973,7 +980,7 @@ function McpSourceForm(props: { · 611 unmodified lines
973 );980 );
974}981}
975 982
976function McpAddPage() {983export function McpAddPage() {
977 const navigation = useSourcePluginNavigation();984 const navigation = useSourcePluginNavigation();
978 const initialValue = mcpInputFromSearch(useSourcePluginSearch());985 const initialValue = mcpInputFromSearch(useSourcePluginSearch());
979 const installation = useLocalInstallation();986 const installation = useLocalInstallation();
@@ -1012,7 +1019,7 @@ function McpAddPage() { · 32 unmodified lines
1012 );1019 );
1013}1020}
1014 1021
1015function McpEditPage(props: {1022export function McpEditPage(props: {
1016 source: Source;1023 source: Source;
1017}) {1024}) {
1018 const navigation = useSourcePluginNavigation();1025 const navigation = useSourcePluginNavigation();
@@ -1085,7 +1092,7 @@ function McpEditPage(props: { · 66 unmodified lines
1085 );1092 );
1086}1093}
1087 1094
1088function McpDetailPage(props: {1095export function McpDetailPage(props: {
1089 source: Source;1096 source: Source;
1090}) {1097}) {
1091 const navigation = useSourcePluginNavigation();1098 const navigation = useSourcePluginNavigation();
@@ -1127,14 +1134,10 @@ function McpDetailPage(props: { · 35 unmodified lines
1127 : config.endpoint ?? "remote";1134 : config.endpoint ?? "remote";
1128 1135
1129 return (1136 return (
1130 <div className="space-y-1">1137 <div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
1131 <div className="font-mono text-xs text-foreground">{location}</div>1138 <span className="font-mono text-foreground">{location}</span>
1132 <div>1139 <Badge variant="muted">Transport: {config.transport ?? "auto"}</Badge>
1133 Transport: <span className="text-foreground">{config.transport ?? "auto"}</span>1140 <Badge variant="muted">Auth: {config.auth.kind}</Badge>
1134 </div>
1135 <div>
1136 Auth: <span className="text-foreground">{config.auth.kind}</span>
1137 </div>
1138 </div>1141 </div>
1139 );1142 );
1140 }, [configResult]);1143 }, [configResult]);
@@ -1265,7 +1268,7 @@ function McpSourceRoute(props: { · 124 unmodified lines
1265 return props.children(source.data);1268 return props.children(source.data);
1266}1269}
1267 1270
1268function McpEditRoute() {1271export function McpEditRoute() {
1269 return (1272 return (
1270 <McpSourceRoute>1273 <McpSourceRoute>
1271 {(source) => <McpEditPage source={source} />}1274 {(source) => <McpEditPage source={source} />}
@@ -1273,33 +1276,10 @@ function McpEditRoute() { · 1 unmodified line
1273 );1276 );
1274}1277}
1275 1278
1276function McpDetailRoute() {1279export function McpDetailRoute() {
1277 return (1280 return (
1278 <McpSourceRoute>1281 <McpSourceRoute>
1279 {(source) => <McpDetailPage source={source} />}1282 {(source) => <McpDetailPage source={source} />}
1280 </McpSourceRoute>1283 </McpSourceRoute>
1281 );1284 );
1282}1285}
1283
1284export 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 @@
1import { defineExecutorFrontendPlugin } from "@executor/react/plugins";
2
3import {
4 McpAddPage,
5 McpDetailRoute,
6 McpEditRoute,
7} from "./components";
8
9export 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
3 "private": true,3 "private": true,
4 "type": "module",4 "type": "module",
5 "exports": {5 "exports": {
6 ".": "./index.tsx"6 ".": "./index.ts"
7 },7 },
8 "files": [8 "files": [
9 "index.tsx"9 "components.tsx",
10 "index.ts"
10 ],11 ],
11 "scripts": {12 "scripts": {
12 "typecheck": "bunx tsc --noEmit -p tsconfig.json"13 "typecheck": "bunx tsc --noEmit -p tsconfig.json"
plugins/mcp/react/tsconfig.json+2−1
@@ -11,6 +11,7 @@ · 10 unmodified lines
11 "types": ["bun-types", "node"]11 "types": ["bun-types", "node"]
12 },12 },
13 "include": [13 "include": [
14 "index.tsx"14 "*.ts",
15 "*.tsx"
15 ]16 ]
16}17}
plugins/openapi/react/components.tsxrenamed from plugins/openapi/react/index.tsx+34−667
@@ -1,7 +1,5 @@
1import type {1import type {
2 Loadable,
3 Source,2 Source,
4 SourceInspection,
5 SourceInspectionToolDetail,3 SourceInspectionToolDetail,
6} from "@executor/react";4} from "@executor/react";
7import {5import {
@@ -11,7 +9,6 @@ import { · 3 unmodified lines
11 useAtomSet,9 useAtomSet,
12 useCreateSecret,10 useCreateSecret,
13 useExecutorMutation,11 useExecutorMutation,
14 usePrefetchToolDetail,
15 useSecrets,12 useSecrets,
16 useSource,13 useSource,
17 useSourceDiscovery,14 useSourceDiscovery,
@@ -22,20 +19,12 @@ import { · 4 unmodified lines
22import {19import {
23 Badge,20 Badge,
24 DocumentPanel,21 DocumentPanel,
25 EmptyState,
26 IconCheck,
27 IconChevron,
28 IconClose,
29 IconCopy,
30 IconFolder,
31 IconPencil,22 IconPencil,
32 IconSearch,
33 IconTool,
34 LoadableBlock,23 LoadableBlock,
35 Markdown,24 SourceToolDetailPanel,
36 MethodBadge,25 SourceToolDiscoveryPanel,
26 SourceToolModelWorkbench,
37 cn,27 cn,
38 defineExecutorFrontendPlugin,
39 parseSourceToolExplorerSearch,28 parseSourceToolExplorerSearch,
40 type SourceToolExplorerSearch,29 type SourceToolExplorerSearch,
41 useSourcePluginNavigation,30 useSourcePluginNavigation,
@@ -168,49 +157,6 @@ const Section = (props: { · 126 unmodified lines
168 </section>157 </section>
169);158);
170 159
171type ToolTreeNode = {
172 segment: string;
173 tool?: SourceInspectionToolDetail["summary"] | SourceInspection["tools"][number];
174 children: Map<string, ToolTreeNode>;
175};
176
177const 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
206const 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
214const isPreviewableSpecUrl = (value: string): boolean => {160const isPreviewableSpecUrl = (value: string): boolean => {
215 const trimmed = value.trim();161 const trimmed = value.trim();
216 if (!/^https?:\/\//i.test(trimmed)) {162 if (!/^https?:\/\//i.test(trimmed)) {
@@ -740,7 +686,7 @@ function OpenApiSourceForm(props: { · 523 unmodified lines
740 );686 );
741}687}
742 688
743function OpenApiAddSourcePage() {689export function OpenApiAddSourcePage() {
744 const navigation = useSourcePluginNavigation();690 const navigation = useSourcePluginNavigation();
745 const initialValue = openApiInputFromSearch(useSourcePluginSearch());691 const initialValue = openApiInputFromSearch(useSourcePluginSearch());
746 const openApiHttpClient = getOpenApiHttpClient();692 const openApiHttpClient = getOpenApiHttpClient();
@@ -775,7 +721,7 @@ function OpenApiAddSourcePage() { · 28 unmodified lines
775 );721 );
776}722}
777 723
778function OpenApiEditSourcePage(props: {724export function OpenApiEditSourcePage(props: {
779 source: Source;725 source: Source;
780}) {726}) {
781 const navigation = useSourcePluginNavigation();727 const navigation = useSourcePluginNavigation();
@@ -848,7 +794,7 @@ function OpenApiEditSourcePage(props: { · 66 unmodified lines
848 );794 );
849}795}
850 796
851function OpenApiSourceDetailPage(props: {797export function OpenApiSourceDetailPage(props: {
852 source: Source;798 source: Source;
853}) {799}) {
854 const navigation = useSourcePluginNavigation();800 const navigation = useSourcePluginNavigation();
@@ -1041,7 +987,7 @@ function OpenApiSourceDetailPage(props: { · 186 unmodified lines
1041 987
1042 <div className="flex flex-1 min-h-0 overflow-hidden">988 <div className="flex flex-1 min-h-0 overflow-hidden">
1043 {tab === "model" ? (989 {tab === "model" ? (
1044 <ModelView990 <SourceToolModelWorkbench
1045 bundle={loadedInspection}991 bundle={loadedInspection}
1046 detail={toolDetail}992 detail={toolDetail}
1047 selectedToolPath={selectedTool?.path ?? null}993 selectedToolPath={selectedTool?.path ?? null}
@@ -1051,10 +997,16 @@ function OpenApiSourceDetailPage(props: { · 3 unmodified lines
1051 tool: toolPath,997 tool: toolPath,
1052 })}998 })}
1053 sourceId={props.source.id}999 sourceId={props.source.id}
1000 renderDetail={(detail) => (
1001 <SourceToolDetailPanel
1002 detail={detail}
1003 renderHeaderMeta={renderOpenApiToolHeaderMeta}
1004 renderSchemaExtras={renderOpenApiToolSchemaExtras}
1005 />
1006 )}
1054 />1007 />
1055 ) : (1008 ) : (
1056 <DiscoveryView1009 <SourceToolDiscoveryPanel
1057 bundle={loadedInspection}
1058 initialQuery={query}1010 initialQuery={query}
1059 discovery={discovery}1011 discovery={discovery}
1060 onSubmitQuery={(nextQuery) =>1012 onSubmitQuery={(nextQuery) =>
@@ -1078,586 +1030,23 @@ function OpenApiSourceDetailPage(props: { · 17 unmodified lines
1078 );1030 );
1079}1031}
1080 1032
1081function ModelView(props: {1033const renderOpenApiToolHeaderMeta = (detail: SourceInspectionToolDetail) =>
1082 bundle: SourceInspection;1034 detail.summary.pathTemplate ? (
1083 detail: Loadable<SourceInspectionToolDetail | null>;1035 <span className="font-mono text-[11px] text-muted-foreground/60">
1084 selectedToolPath: string | null;1036 {detail.summary.pathTemplate}
1085 onSelectTool: (toolPath: string) => void;1037 </span>
1086 sourceId: string;1038 ) : null;
1087}) {1039
1088 const [search, setSearch] = useState("");1040const renderOpenApiToolSchemaExtras = (detail: SourceInspectionToolDetail) => (
1089 const searchRef = useRef<HTMLInputElement>(null);1041 <>
1090 const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean);1042 {detail.contract.input.exampleJson && (
1091 1043 <DocumentPanel title="Example request" body={detail.contract.input.exampleJson} empty="" compact />
1092 const filteredTools = props.bundle.tools.filter((tool) => {1044 )}
1093 if (terms.length === 0) return true;1045 {detail.contract.output.exampleJson && (
1094 const corpus = [1046 <DocumentPanel title="Example response" body={detail.contract.output.exampleJson} empty="" compact />
1095 tool.path,1047 )}
1096 tool.method ?? "",1048 </>
1097 tool.inputTypePreview ?? "",1049);
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
1187function 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
1218function 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
1344function 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
1393function 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
1496function 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
1588function 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
1608function 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}
1661 1050
1662function OpenApiSourceRoute(props: {1051function OpenApiSourceRoute(props: {
1663 children: (source: Source) => ReactNode;1052 children: (source: Source) => ReactNode;
@@ -1693,7 +1082,7 @@ function OpenApiSourceRoute(props: { · 29 unmodified lines
1693 return props.children(source.data);1082 return props.children(source.data);
1694}1083}
1695 1084
1696function OpenApiDetailRoute() {1085export function OpenApiDetailRoute() {
1697 return (1086 return (
1698 <OpenApiSourceRoute>1087 <OpenApiSourceRoute>
1699 {(source) => <OpenApiSourceDetailPage source={source} />}1088 {(source) => <OpenApiSourceDetailPage source={source} />}
@@ -1701,32 +1090,10 @@ function OpenApiDetailRoute() { · 1 unmodified line
1701 );1090 );
1702}1091}
1703 1092
1704function OpenApiEditRoute() {1093export function OpenApiEditRoute() {
1705 return (1094 return (
1706 <OpenApiSourceRoute>1095 <OpenApiSourceRoute>
1707 {(source) => <OpenApiEditSourcePage source={source} />}1096 {(source) => <OpenApiEditSourcePage source={source} />}
1708 </OpenApiSourceRoute>1097 </OpenApiSourceRoute>
1709 );1098 );
1710}1099}
1711
1712export 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 @@
1import { defineExecutorFrontendPlugin } from "@executor/react/plugins";
2
3import {
4 OpenApiAddSourcePage,
5 OpenApiDetailRoute,
6 OpenApiEditRoute,
7} from "./components";
8
9export 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
3 "private": true,3 "private": true,
4 "type": "module",4 "type": "module",
5 "exports": {5 "exports": {
6 ".": "./index.tsx"6 ".": "./index.ts"
7 },7 },
8 "files": [8 "files": [
9 "index.tsx"9 "components.tsx",
10 "index.ts"
10 ],11 ],
11 "scripts": {12 "scripts": {
12 "typecheck": "bunx tsc --noEmit -p tsconfig.json"13 "typecheck": "bunx tsc --noEmit -p tsconfig.json"
plugins/openapi/react/tsconfig.json+2−1
@@ -11,6 +11,7 @@ · 10 unmodified lines
11 "types": ["bun-types", "node"]11 "types": ["bun-types", "node"]
12 },12 },
13 "include": [13 "include": [
14 "index.tsx"14 "*.ts",
15 "*.tsx"
15 ]16 ]
16}17}