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