Unified · Split
| @@ -23,6 +23,16 @@ const githubUrl = (executorPackage.homepage ?? repositoryUrl ?? "https://github. · 22 unmodified lines | |||
| 23 | .replace(/^git\+/, "") | 23 | .replace(/^git\+/, "") |
| 24 | .replace(/\.git$/, ""); | 24 | .replace(/\.git$/, ""); |
| 25 | 25 | ||
| 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 | export default defineConfig({ | 36 | export default defineConfig({ |
| 27 | root: "src", | 37 | root: "src", |
| 28 | plugins: [ | 38 | plugins: [ |
| @@ -47,7 +57,15 @@ export default defineConfig({ · 18 unmodified lines | |||
| 47 | outDir: "../dist", | 57 | outDir: "../dist", |
| 48 | emptyOutDir: true, | 58 | emptyOutDir: true, |
| 49 | }, | 59 | }, |
| 60 | optimizeDeps: { | ||
| 61 | exclude: workspaceExecutorPackages, | ||
| 62 | }, | ||
| 50 | server: { | 63 | server: { |
| 51 | port: 8788, | 64 | port: 8788, |
| 65 | watch: { | ||
| 66 | ignored: [ | ||
| 67 | "!**/node_modules/@executor/**", | ||
| 68 | ], | ||
| 69 | }, | ||
| 52 | }, | 70 | }, |
| 53 | }); | 71 | }); |
| @@ -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 | import { | 3 | import { |
| 9 | useSourceDiscovery, | 4 | useSourceDiscovery, |
| 10 | useSourceInspection, | 5 | useSourceInspection, |
| 11 | useSourceToolDetail, | 6 | useSourceToolDetail, |
| 12 | } from "../../hooks/sources"; | 7 | } from "../../hooks/sources"; |
| 13 | import { Badge, MethodBadge } from "./badge"; | 8 | import { Badge } from "./badge"; |
| 14 | import { DocumentPanel } from "./document-panel"; | 9 | import { LoadableBlock } from "./loadable"; |
| 15 | import { EmptyState, LoadableBlock } from "./loadable"; | ||
| 16 | import { Markdown } from "./markdown"; | ||
| 17 | import { | 10 | import { |
| 18 | IconCheck, | 11 | SourceToolDiscoveryPanel, |
| 19 | IconCopy, | 12 | SourceToolModelWorkbench, |
| 20 | IconSearch, | 13 | } from "./source-tool-workbench"; |
| 21 | IconTool, | 14 | import type { SourceToolExplorerSearch } from "./source-tool-explorer-search"; |
| 22 | } from "./icons"; | ||
| 23 | import { cn } from "../lib/cn"; | 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 | export const SourceToolExplorer = (props: { | 17 | export const SourceToolExplorer = (props: { |
| 261 | sourceId: string; | 18 | sourceId: string; |
| 262 | title: string; | 19 | title: string; |
| 263 | kind: string; | 20 | kind: string; |
| 264 | search: SourceToolExplorerSearch; | 21 | search: SourceToolExplorerSearch; |
| 265 | modelLayout?: "split" | "detail"; | ||
| 266 | navigate?: (search: { | 22 | navigate?: (search: { |
| 267 | tab: "model" | "discover"; | 23 | tab: "model" | "discover"; |
| 268 | tool?: string; | 24 | tool?: string; |
| @@ -275,9 +31,12 @@ export const SourceToolExplorer = (props: { · 6 unmodified lines | |||
| 275 | const tab = props.search.tab === "discover" ? "discover" : "model"; | 31 | const tab = props.search.tab === "discover" ? "discover" : "model"; |
| 276 | const toolPath = props.search.tool ?? null; | 32 | const toolPath = props.search.tool ?? null; |
| 277 | const detail = useSourceToolDetail(props.sourceId, tab === "model" ? toolPath : null); | 33 | const detail = useSourceToolDetail(props.sourceId, tab === "model" ? toolPath : null); |
| 278 | const [filter, setFilter] = useState(""); | 34 | const discovery = useSourceDiscovery({ |
| 35 | sourceId: props.sourceId, | ||
| 36 | query: props.search.query ?? "", | ||
| 37 | limit: 20, | ||
| 38 | }); | ||
| 279 | const navigate = props.navigate; | 39 | const navigate = props.navigate; |
| 280 | const modelLayout = props.modelLayout ?? "split"; | ||
| 281 | 40 | ||
| 282 | useEffect(() => { | 41 | useEffect(() => { |
| 283 | if ( | 42 | if ( |
| @@ -310,206 +69,113 @@ export const SourceToolExplorer = (props: { · 26 unmodified lines | |||
| 310 | loadedInspection.tools.find((candidate) => candidate.path === toolPath) | 69 | loadedInspection.tools.find((candidate) => candidate.path === toolPath) |
| 311 | ?? loadedInspection.tools[0] | 70 | ?? loadedInspection.tools[0] |
| 312 | ?? null; | 71 | ?? null; |
| 313 | const visibleTools = loadedInspection.tools.filter((tool) => { | ||
| 314 | const query = filter.trim().toLowerCase(); | ||
| 315 | if (!query) { | ||
| 316 | return true; | ||
| 317 | } | ||
| 318 | |||
| 319 | return [ | ||
| 320 | tool.path, | ||
| 321 | tool.method ?? "", | ||
| 322 | tool.inputTypePreview ?? "", | ||
| 323 | tool.outputTypePreview ?? "", | ||
| 324 | ] | ||
| 325 | .join(" ") | ||
| 326 | .toLowerCase() | ||
| 327 | .includes(query); | ||
| 328 | }); | ||
| 329 | 72 | ||
| 330 | return ( | 73 | return ( |
| 331 | <div className="space-y-4"> | 74 | <div className="flex h-full flex-col overflow-hidden"> |
| 332 | <div className="rounded-2xl border border-border bg-card px-5 py-4"> | 75 | <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background/95 px-4 backdrop-blur-sm"> |
| 333 | <div className="flex items-start justify-between gap-4"> | 76 | <div className="flex min-w-0 items-center gap-3"> |
| 334 | <div className="min-w-0"> | 77 | <h2 className="truncate text-sm font-semibold text-foreground"> |
| 335 | <div className="flex flex-wrap items-center gap-2"> | 78 | {props.title} |
| 336 | <h2 className="truncate text-lg font-semibold text-foreground"> | 79 | </h2> |
| 337 | {props.title} | 80 | <Badge variant="outline">{props.kind}</Badge> |
| 338 | </h2> | 81 | </div> |
| 339 | <Badge variant="outline">{props.kind}</Badge> | 82 | <div className="flex items-center gap-2"> |
| 340 | <Badge variant="muted"> | 83 | <div className="flex items-center gap-0.5 rounded-lg bg-muted/50 p-0.5"> |
| 341 | {loadedInspection.toolCount} tool{loadedInspection.toolCount === 1 ? "" : "s"} | 84 | {(["model", "discover"] as const).map((tabId) => ( |
| 342 | </Badge> | 85 | <button |
| 343 | </div> | 86 | key={tabId} |
| 344 | {props.summary && ( | 87 | type="button" |
| 345 | <div className="mt-3 text-sm text-muted-foreground"> | 88 | onClick={() => { |
| 346 | {props.summary} | 89 | if (!navigate) { |
| 347 | </div> | 90 | return; |
| 348 | )} | 91 | } |
| 349 | </div> | 92 | |
| 350 | <div className="flex items-center gap-2"> | 93 | startTransition(() => { |
| 351 | <div className="inline-flex rounded-lg bg-muted p-0.5"> | 94 | void navigate({ |
| 352 | {(["model", "discover"] as const).map((entry) => ( | 95 | tab: tabId, |
| 353 | <button | 96 | tool: |
| 354 | key={entry} | 97 | tabId === "model" |
| 355 | type="button" | 98 | ? (selectedTool?.path ?? undefined) |
| 356 | onClick={() => { | 99 | : props.search.tool, |
| 357 | if (!navigate) { | 100 | query: props.search.query, |
| 358 | return; | ||
| 359 | } | ||
| 360 | |||
| 361 | startTransition(() => { | ||
| 362 | void navigate({ | ||
| 363 | tab: entry, | ||
| 364 | tool: entry === "model" ? (selectedTool?.path ?? undefined) : props.search.tool, | ||
| 365 | query: props.search.query, | ||
| 366 | }); | ||
| 367 | }); | 101 | }); |
| 368 | }} | 102 | }); |
| 369 | className={cn( | 103 | }} |
| 370 | "rounded-md px-3 py-1 text-xs font-medium transition-colors", | 104 | className={cn( |
| 371 | tab === entry | 105 | "rounded-md px-3 py-1 text-[12px] font-medium transition-colors", |
| 372 | ? "bg-background text-foreground shadow-sm" | 106 | tabId === tab |
| 373 | : "text-muted-foreground hover:text-foreground", | 107 | ? "bg-background text-foreground shadow-sm" |
| 374 | )} | 108 | : "text-muted-foreground hover:text-foreground", |
| 375 | > | 109 | )} |
| 376 | {entry === "model" ? "Tools" : "Search"} | 110 | > |
| 377 | </button> | 111 | {tabId === "model" ? "Tools" : "Search"} |
| 378 | ))} | 112 | </button> |
| 379 | </div> | 113 | ))} |
| 380 | {props.actions} | ||
| 381 | </div> | 114 | </div> |
| 115 | {props.actions} | ||
| 382 | </div> | 116 | </div> |
| 383 | </div> | 117 | </div> |
| 384 | 118 | ||
| 385 | {tab === "discover" ? ( | 119 | {props.summary && ( |
| 386 | <DiscoveryPanel | 120 | <div className="shrink-0 border-b border-border bg-card/30 px-4 py-2"> |
| 387 | sourceId={props.sourceId} | 121 | {props.summary} |
| 388 | query={props.search.query ?? ""} | 122 | </div> |
| 389 | onQueryChange={(query) => { | 123 | )} |
| 390 | if (!navigate) { | ||
| 391 | return; | ||
| 392 | } | ||
| 393 | 124 | ||
| 394 | startTransition(() => { | 125 | <div className="flex min-h-0 flex-1 overflow-hidden"> |
| 395 | void navigate({ | 126 | {tab === "model" ? ( |
| 396 | tab: "discover", | 127 | <SourceToolModelWorkbench |
| 397 | tool: props.search.tool, | 128 | bundle={loadedInspection} |
| 398 | query, | 129 | detail={detail} |
| 399 | }); | 130 | selectedToolPath={selectedTool?.path ?? null} |
| 400 | }); | 131 | onSelectTool={(nextToolPath) => { |
| 401 | }} | 132 | if (!navigate) { |
| 402 | onOpenTool={(path) => { | 133 | return; |
| 403 | if (!navigate) { | 134 | } |
| 404 | return; | ||
| 405 | } | ||
| 406 | 135 | ||
| 407 | startTransition(() => { | 136 | startTransition(() => { |
| 408 | void navigate({ | 137 | void navigate({ |
| 409 | tab: "model", | 138 | tab: "model", |
| 410 | tool: path, | 139 | tool: nextToolPath, |
| 411 | query: props.search.query, | 140 | query: props.search.query, |
| 141 | }); | ||
| 412 | }); | 142 | }); |
| 413 | }); | 143 | }} |
| 414 | }} | 144 | sourceId={props.sourceId} |
| 415 | /> | 145 | /> |
| 416 | ) : ( | ||
| 417 | modelLayout === "detail" ? ( | ||
| 418 | <div className="rounded-2xl border border-border bg-card p-4"> | ||
| 419 | <LoadableBlock loadable={detail} loading="Loading tool..."> | ||
| 420 | {(loadedDetail) => | ||
| 421 | loadedDetail ? ( | ||
| 422 | <ToolDetailPanel detail={loadedDetail} /> | ||
| 423 | ) : ( | ||
| 424 | <EmptyState | ||
| 425 | title="Tool unavailable" | ||
| 426 | description="The selected tool could not be loaded." | ||
| 427 | /> | ||
| 428 | ) | ||
| 429 | } | ||
| 430 | </LoadableBlock> | ||
| 431 | </div> | ||
| 432 | ) : ( | 146 | ) : ( |
| 433 | <div className="flex min-h-0 flex-col gap-4 md:flex-row"> | 147 | <SourceToolDiscoveryPanel |
| 434 | <div | 148 | discovery={discovery} |
| 435 | className={cn( | 149 | initialQuery={props.search.query ?? ""} |
| 436 | "space-y-3 rounded-2xl border border-border bg-card p-4 md:w-72 md:shrink-0 xl:w-80", | 150 | onSubmitQuery={(query) => { |
| 437 | selectedTool && "order-2 md:order-1", | 151 | if (!navigate) { |
| 438 | )} | 152 | return; |
| 439 | > | 153 | } |
| 440 | <div className="relative"> | ||
| 441 | <IconSearch className="absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/40" /> | ||
| 442 | <input | ||
| 443 | value={filter} | ||
| 444 | onChange={(event) => setFilter(event.target.value)} | ||
| 445 | placeholder="Filter tools..." | ||
| 446 | className="h-9 w-full rounded-lg border border-input bg-background pl-9 pr-3 text-sm outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 447 | /> | ||
| 448 | </div> | ||
| 449 | <div className="space-y-1"> | ||
| 450 | {visibleTools.length === 0 ? ( | ||
| 451 | <EmptyState | ||
| 452 | title="No tools match" | ||
| 453 | description="Try a different filter." | ||
| 454 | /> | ||
| 455 | ) : ( | ||
| 456 | visibleTools.map((tool) => ( | ||
| 457 | <button | ||
| 458 | key={tool.path} | ||
| 459 | type="button" | ||
| 460 | onClick={() => { | ||
| 461 | if (!navigate) { | ||
| 462 | return; | ||
| 463 | } | ||
| 464 | 154 | ||
| 465 | startTransition(() => { | 155 | startTransition(() => { |
| 466 | void navigate({ | 156 | void navigate({ |
| 467 | tab: "model", | 157 | tab: "discover", |
| 468 | tool: tool.path, | 158 | tool: props.search.tool, |
| 469 | query: props.search.query, | 159 | query, |
| 470 | }); | 160 | }); |
| 471 | }); | 161 | }); |
| 472 | }} | 162 | }} |
| 473 | className={cn( | 163 | onOpenTool={(nextToolPath) => { |
| 474 | "flex w-full items-center gap-2 rounded-lg border px-3 py-2 text-left transition-colors", | 164 | if (!navigate) { |
| 475 | selectedTool?.path === tool.path | 165 | return; |
| 476 | ? "border-primary/30 bg-primary/8" | 166 | } |
| 477 | : "border-transparent bg-muted/30 hover:border-border hover:bg-card", | ||
| 478 | )} | ||
| 479 | > | ||
| 480 | <IconTool className="size-3.5 shrink-0 text-muted-foreground/60" /> | ||
| 481 | <span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground"> | ||
| 482 | {highlight(tool.path, filter)} | ||
| 483 | </span> | ||
| 484 | {tool.method && <MethodBadge method={tool.method} />} | ||
| 485 | </button> | ||
| 486 | )) | ||
| 487 | )} | ||
| 488 | </div> | ||
| 489 | </div> | ||
| 490 | 167 | ||
| 491 | <div | 168 | startTransition(() => { |
| 492 | className={cn( | 169 | void navigate({ |
| 493 | "min-w-0 flex-1 rounded-2xl border border-border bg-card p-4", | 170 | tab: "model", |
| 494 | selectedTool && "order-1 md:order-2", | 171 | tool: nextToolPath, |
| 495 | )} | 172 | query: props.search.query, |
| 496 | > | 173 | }); |
| 497 | <LoadableBlock loadable={detail} loading="Loading tool..."> | 174 | }); |
| 498 | {(loadedDetail) => | 175 | }} |
| 499 | loadedDetail ? ( | 176 | /> |
| 500 | <ToolDetailPanel detail={loadedDetail} /> | 177 | )} |
| 501 | ) : ( | 178 | </div> |
| 502 | <EmptyState | ||
| 503 | title="Select a tool" | ||
| 504 | description="Choose a tool from the list to inspect its contract." | ||
| 505 | /> | ||
| 506 | ) | ||
| 507 | } | ||
| 508 | </LoadableBlock> | ||
| 509 | </div> | ||
| 510 | </div> | ||
| 511 | ) | ||
| 512 | )} | ||
| 513 | </div> | 179 | </div> |
| 514 | ); | 180 | ); |
| 515 | }} | 181 | }} |
| @@ -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 | } from "./components/markdown"; | 92 | } from "./components/markdown"; |
| 93 | export { | 93 | export { |
| 94 | SourceToolExplorer, | 94 | SourceToolExplorer, |
| 95 | } from "./components/source-tool-explorer"; | ||
| 96 | export { | ||
| 95 | type SourceToolExplorerSearch, | 97 | type SourceToolExplorerSearch, |
| 96 | parseSourceToolExplorerSearch, | 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 | useSource, | 12 | useSource, |
| 13 | } from "@executor/react"; | 13 | } from "@executor/react"; |
| 14 | import { | 14 | import { |
| 15 | Badge, | ||
| 15 | IconPencil, | 16 | IconPencil, |
| 16 | SourceToolExplorer, | 17 | SourceToolExplorer, |
| 17 | defineExecutorFrontendPlugin, | ||
| 18 | parseSourceToolExplorerSearch, | 18 | parseSourceToolExplorerSearch, |
| 19 | type SourceToolExplorerSearch, | 19 | type SourceToolExplorerSearch, |
| 20 | useSourcePluginNavigation, | 20 | useSourcePluginNavigation, |
| @@ -38,48 +38,10 @@ import { · 17 unmodified lines | |||
| 38 | type GoogleDiscoveryStartBatchOAuthInput, | 38 | type GoogleDiscoveryStartBatchOAuthInput, |
| 39 | type GoogleDiscoveryStartOAuthInput, | 39 | type GoogleDiscoveryStartOAuthInput, |
| 40 | } from "@executor/plugin-google-discovery-shared"; | 40 | } from "@executor/plugin-google-discovery-shared"; |
| 41 | import { getGoogleDiscoveryIconUrlForService } from "./icons"; | ||
| 41 | 42 | ||
| 42 | const OAUTH_TIMEOUT_MS = 2 * 60_000; | 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 | const getGoogleDiscoveryHttpClient = | 45 | const getGoogleDiscoveryHttpClient = |
| 84 | defineExecutorPluginHttpApiClient<"GoogleDiscoveryReactHttpClient">()( | 46 | defineExecutorPluginHttpApiClient<"GoogleDiscoveryReactHttpClient">()( |
| 85 | "GoogleDiscoveryReactHttpClient", | 47 | "GoogleDiscoveryReactHttpClient", |
| @@ -477,7 +439,14 @@ const openGoogleOauthPopup = (name: string): Window | null => { · 391 unmodified lines | |||
| 477 | <main style="font-family: system-ui, -apple-system, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; color: #333;"> | 439 | <main style="font-family: system-ui, -apple-system, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; color: #333;"> |
| 478 | <div style="width: 20px; height: 20px; border: 2px solid #e5e7eb; border-top-color: #6366f1; border-radius: 50%; animation: spin 0.7s linear infinite; margin-bottom: 16px;"></div> | 440 | <div style="width: 20px; height: 20px; border: 2px solid #e5e7eb; border-top-color: #6366f1; border-radius: 50%; animation: spin 0.7s linear infinite; margin-bottom: 16px;"></div> |
| 479 | <p style="margin: 0; font-size: 14px; color: #888;">Redirecting to Google…</p> | 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 | </main> | 450 | </main> |
| 482 | `; | 451 | `; |
| 483 | } catch { | 452 | } catch { |
| @@ -1058,8 +1027,7 @@ function GoogleDiscoveryBatchConnectPanel(props: { · 574 unmodified lines | |||
| 1058 | <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> | 1027 | <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> |
| 1059 | {GOOGLE_DISCOVERY_BATCH_TEMPLATES.map((template) => { | 1028 | {GOOGLE_DISCOVERY_BATCH_TEMPLATES.map((template) => { |
| 1060 | const selected = selectedTemplateIds.includes(template.id); | 1029 | const selected = selectedTemplateIds.includes(template.id); |
| 1061 | const serviceKey = template.service.replaceAll(".", ""); | 1030 | const iconUrl = getGoogleDiscoveryIconUrlForService(template.service); |
| 1062 | const iconUrl = GOOGLE_DISCOVERY_SERVICE_ICONS[serviceKey] ?? null; | ||
| 1063 | 1031 | ||
| 1064 | return ( | 1032 | return ( |
| 1065 | <button | 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 | const navigation = useSourcePluginNavigation(); | 1138 | const navigation = useSourcePluginNavigation(); |
| 1171 | const initialValue = googleInputFromSearch(useSourcePluginSearch()); | 1139 | const initialValue = googleInputFromSearch(useSourcePluginSearch()); |
| 1172 | const installation = useLocalInstallation(); | 1140 | const installation = useLocalInstallation(); |
| @@ -1230,7 +1198,7 @@ function GoogleDiscoveryAddPage() { · 57 unmodified lines | |||
| 1230 | ); | 1198 | ); |
| 1231 | } | 1199 | } |
| 1232 | 1200 | ||
| 1233 | function GoogleDiscoveryEditPage(props: { | 1201 | export function GoogleDiscoveryEditPage(props: { |
| 1234 | source: Source; | 1202 | source: Source; |
| 1235 | }) { | 1203 | }) { |
| 1236 | const navigation = useSourcePluginNavigation(); | 1204 | const navigation = useSourcePluginNavigation(); |
| @@ -1303,7 +1271,7 @@ function GoogleDiscoveryEditPage(props: { · 66 unmodified lines | |||
| 1303 | ); | 1271 | ); |
| 1304 | } | 1272 | } |
| 1305 | 1273 | ||
| 1306 | function GoogleDiscoveryDetailPage(props: { | 1274 | export function GoogleDiscoveryDetailPage(props: { |
| 1307 | source: Source; | 1275 | source: Source; |
| 1308 | }) { | 1276 | }) { |
| 1309 | const navigation = useSourcePluginNavigation(); | 1277 | const navigation = useSourcePluginNavigation(); |
| @@ -1341,9 +1309,9 @@ function GoogleDiscoveryDetailPage(props: { · 31 unmodified lines | |||
| 1341 | 1309 | ||
| 1342 | const config = configResult.value; | 1310 | const config = configResult.value; |
| 1343 | return ( | 1311 | return ( |
| 1344 | <div className="space-y-1 text-xs text-muted-foreground"> | 1312 | <div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground"> |
| 1345 | <div className="text-foreground">{config.service} {config.version}</div> | 1313 | <Badge variant="muted">{config.service} {config.version}</Badge> |
| 1346 | <div className="font-mono truncate"> | 1314 | <div className="font-mono text-foreground"> |
| 1347 | {config.discoveryUrl ?? defaultGoogleDiscoveryUrl(config.service, config.version)} | 1315 | {config.discoveryUrl ?? defaultGoogleDiscoveryUrl(config.service, config.version)} |
| 1348 | </div> | 1316 | </div> |
| 1349 | </div> | 1317 | </div> |
| @@ -1476,7 +1444,7 @@ function GoogleDiscoverySourceRoute(props: { · 126 unmodified lines | |||
| 1476 | return props.children(source.data); | 1444 | return props.children(source.data); |
| 1477 | } | 1445 | } |
| 1478 | 1446 | ||
| 1479 | function GoogleDiscoveryEditRoute() { | 1447 | export function GoogleDiscoveryEditRoute() { |
| 1480 | return ( | 1448 | return ( |
| 1481 | <GoogleDiscoverySourceRoute> | 1449 | <GoogleDiscoverySourceRoute> |
| 1482 | {(source) => <GoogleDiscoveryEditPage source={source} />} | 1450 | {(source) => <GoogleDiscoveryEditPage source={source} />} |
| @@ -1484,33 +1452,10 @@ function GoogleDiscoveryEditRoute() { · 1 unmodified line | |||
| 1484 | ); | 1452 | ); |
| 1485 | } | 1453 | } |
| 1486 | 1454 | ||
| 1487 | function GoogleDiscoveryDetailRoute() { | 1455 | export function GoogleDiscoveryDetailRoute() { |
| 1488 | return ( | 1456 | return ( |
| 1489 | <GoogleDiscoverySourceRoute> | 1457 | <GoogleDiscoverySourceRoute> |
| 1490 | {(source) => <GoogleDiscoveryDetailPage source={source} />} | 1458 | {(source) => <GoogleDiscoveryDetailPage source={source} />} |
| 1491 | </GoogleDiscoverySourceRoute> | 1459 | </GoogleDiscoverySourceRoute> |
| 1492 | ); | 1460 | ); |
| 1493 | } | 1461 | } |
| 1494 | |||
| 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 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | 5 | "exports": { |
| 6 | ".": "./index.tsx" | 6 | ".": "./index.ts" |
| 7 | }, | 7 | }, |
| 8 | "files": [ | 8 | "files": [ |
| 9 | "index.tsx" | 9 | "components.tsx", |
| 10 | "icons.ts", | ||
| 11 | "index.ts" | ||
| 10 | ], | 12 | ], |
| 11 | "scripts": { | 13 | "scripts": { |
| 12 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | 14 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" |
| @@ -11,6 +11,7 @@ · 10 unmodified lines | |||
| 11 | "types": ["bun-types", "node"] | 11 | "types": ["bun-types", "node"] |
| 12 | }, | 12 | }, |
| 13 | "include": [ | 13 | "include": [ |
| 14 | "index.tsx" | 14 | "*.ts", |
| 15 | "*.tsx" | ||
| 15 | ] | 16 | ] |
| 16 | } | 17 | } |
| @@ -1,9 +1,6 @@ | |||
| 1 | import { startTransition, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | 1 | import { startTransition, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 2 | import type { | 2 | import type { |
| 3 | Loadable, | ||
| 4 | Source, | 3 | Source, |
| 5 | SourceInspection, | ||
| 6 | SourceInspectionDiscoverResult, | ||
| 7 | SourceInspectionToolDetail, | 4 | SourceInspectionToolDetail, |
| 8 | } from "@executor/react"; | 5 | } from "@executor/react"; |
| 9 | import { | 6 | import { |
| @@ -14,7 +11,6 @@ import { · 4 unmodified lines | |||
| 14 | useCreateSecret, | 11 | useCreateSecret, |
| 15 | useExecutorMutation, | 12 | useExecutorMutation, |
| 16 | useLocalInstallation, | 13 | useLocalInstallation, |
| 17 | usePrefetchToolDetail, | ||
| 18 | useSecrets, | 14 | useSecrets, |
| 19 | useSource, | 15 | useSource, |
| 20 | useSourceDiscovery, | 16 | useSourceDiscovery, |
| @@ -24,20 +20,12 @@ import { · 3 unmodified lines | |||
| 24 | import { | 20 | import { |
| 25 | Badge, | 21 | Badge, |
| 26 | DocumentPanel, | 22 | DocumentPanel, |
| 27 | EmptyState, | ||
| 28 | IconCheck, | ||
| 29 | IconChevron, | ||
| 30 | IconClose, | ||
| 31 | IconCopy, | ||
| 32 | IconFolder, | ||
| 33 | IconPencil, | 23 | IconPencil, |
| 34 | IconSearch, | ||
| 35 | IconTool, | ||
| 36 | LoadableBlock, | 24 | LoadableBlock, |
| 37 | Markdown, | 25 | SourceToolDetailPanel, |
| 38 | MethodBadge, | 26 | SourceToolDiscoveryPanel, |
| 27 | SourceToolModelWorkbench, | ||
| 39 | cn, | 28 | cn, |
| 40 | defineExecutorFrontendPlugin, | ||
| 41 | parseSourceToolExplorerSearch, | 29 | parseSourceToolExplorerSearch, |
| 42 | useSourcePluginRouteParams, | 30 | useSourcePluginRouteParams, |
| 43 | type SourceToolExplorerSearch, | 31 | type SourceToolExplorerSearch, |
| @@ -63,12 +51,6 @@ type GraphqlToolRouteParams = { · 19 unmodified lines | |||
| 63 | toolPath?: string; | 51 | toolPath?: string; |
| 64 | }; | 52 | }; |
| 65 | 53 | ||
| 66 | type GraphqlToolTreeNode = { | ||
| 67 | segment: string; | ||
| 68 | tool?: SourceInspectionToolDetail["summary"] | SourceInspection["tools"][number]; | ||
| 69 | children: Map<string, GraphqlToolTreeNode>; | ||
| 70 | }; | ||
| 71 | |||
| 72 | const defaultGraphqlInput = (): GraphqlConnectInput => ({ | 54 | const defaultGraphqlInput = (): GraphqlConnectInput => ({ |
| 73 | name: "My GraphQL Source", | 55 | name: "My GraphQL Source", |
| 74 | endpoint: "https://example.com/graphql", | 56 | endpoint: "https://example.com/graphql", |
| @@ -338,47 +320,6 @@ const readGraphqlToolPath = (params: GraphqlToolRouteParams): string | null => { · 263 unmodified lines | |||
| 338 | } | 320 | } |
| 339 | }; | 321 | }; |
| 340 | 322 | ||
| 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 | function GraphqlSourceForm(props: { | 323 | function GraphqlSourceForm(props: { |
| 383 | initialValue: GraphqlConnectInput; | 324 | initialValue: GraphqlConnectInput; |
| 384 | mode: "create" | "edit"; | 325 | mode: "create" | "edit"; |
| @@ -524,7 +465,7 @@ function GraphqlSourceForm(props: { · 139 unmodified lines | |||
| 524 | ); | 465 | ); |
| 525 | } | 466 | } |
| 526 | 467 | ||
| 527 | function GraphqlAddPage() { | 468 | export function GraphqlAddPage() { |
| 528 | const navigation = useSourcePluginNavigation(); | 469 | const navigation = useSourcePluginNavigation(); |
| 529 | const initialValue = graphqlInputFromSearch(useSourcePluginSearch()); | 470 | const initialValue = graphqlInputFromSearch(useSourcePluginSearch()); |
| 530 | const installation = useLocalInstallation(); | 471 | const installation = useLocalInstallation(); |
| @@ -563,7 +504,7 @@ function GraphqlAddPage() { · 32 unmodified lines | |||
| 563 | ); | 504 | ); |
| 564 | } | 505 | } |
| 565 | 506 | ||
| 566 | function GraphqlEditPage(props: { | 507 | export function GraphqlEditPage(props: { |
| 567 | source: Source; | 508 | source: Source; |
| 568 | }) { | 509 | }) { |
| 569 | const navigation = useSourcePluginNavigation(); | 510 | const navigation = useSourcePluginNavigation(); |
| @@ -876,7 +817,7 @@ function GraphqlDetailExplorer(props: { · 306 unmodified lines | |||
| 876 | 817 | ||
| 877 | <div className="flex min-h-0 flex-1 overflow-hidden"> | 818 | <div className="flex min-h-0 flex-1 overflow-hidden"> |
| 878 | {tab === "model" ? ( | 819 | {tab === "model" ? ( |
| 879 | <GraphqlModelView | 820 | <SourceToolModelWorkbench |
| 880 | bundle={loadedInspection} | 821 | bundle={loadedInspection} |
| 881 | detail={toolDetail} | 822 | detail={toolDetail} |
| 882 | selectedToolPath={activeTool?.path ?? null} | 823 | selectedToolPath={activeTool?.path ?? null} |
| @@ -887,9 +828,15 @@ function GraphqlDetailExplorer(props: { · 4 unmodified lines | |||
| 887 | }); | 828 | }); |
| 888 | }} | 829 | }} |
| 889 | sourceId={props.source.id} | 830 | sourceId={props.source.id} |
| 831 | renderDetail={(detail) => ( | ||
| 832 | <SourceToolDetailPanel | ||
| 833 | detail={detail} | ||
| 834 | renderHeaderMeta={renderGraphqlToolHeaderMeta} | ||
| 835 | /> | ||
| 836 | )} | ||
| 890 | /> | 837 | /> |
| 891 | ) : ( | 838 | ) : ( |
| 892 | <GraphqlDiscoveryView | 839 | <SourceToolDiscoveryPanel |
| 893 | discovery={discovery} | 840 | discovery={discovery} |
| 894 | initialQuery={query} | 841 | initialQuery={query} |
| 895 | onOpenTool={(toolPath) => { | 842 | onOpenTool={(toolPath) => { |
| @@ -915,673 +862,24 @@ function GraphqlDetailExplorer(props: { · 19 unmodified lines | |||
| 915 | ); | 862 | ); |
| 916 | } | 863 | } |
| 917 | 864 | ||
| 918 | function GraphqlModelView(props: { | 865 | const renderGraphqlToolHeaderMeta = (detail: SourceInspectionToolDetail) => { |
| 919 | bundle: SourceInspection; | ||
| 920 | detail: Loadable<SourceInspectionToolDetail | null>; | ||
| 921 | selectedToolPath: string | null; | ||
| 922 | onSelectTool: (toolPath: string) => void; | ||
| 923 | sourceId: string; | ||
| 924 | }) { | ||
| 925 | const [search, setSearch] = useState(""); | ||
| 926 | const searchRef = useRef<HTMLInputElement>(null); | ||
| 927 | const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean); | ||
| 928 | const filteredTools = useMemo( | ||
| 929 | () => | ||
| 930 | props.bundle.tools.filter((tool) => { | ||
| 931 | if (terms.length === 0) { | ||
| 932 | return true; | ||
| 933 | } | ||
| 934 | |||
| 935 | const corpus = [ | ||
| 936 | tool.path, | ||
| 937 | tool.method ?? "", | ||
| 938 | tool.inputTypePreview ?? "", | ||
| 939 | tool.outputTypePreview ?? "", | ||
| 940 | ] | ||
| 941 | .join(" ") | ||
| 942 | .toLowerCase(); | ||
| 943 | |||
| 944 | return terms.every((term) => corpus.includes(term)); | ||
| 945 | }), | ||
| 946 | [props.bundle.tools, terms], | ||
| 947 | ); | ||
| 948 | |||
| 949 | useEffect(() => { | ||
| 950 | const handler = (event: KeyboardEvent) => { | ||
| 951 | if (event.key === "/" && document.activeElement?.tagName !== "INPUT") { | ||
| 952 | event.preventDefault(); | ||
| 953 | searchRef.current?.focus(); | ||
| 954 | } | ||
| 955 | |||
| 956 | if (event.key === "Escape") { | ||
| 957 | searchRef.current?.blur(); | ||
| 958 | if (search.length > 0) { | ||
| 959 | setSearch(""); | ||
| 960 | } | ||
| 961 | } | ||
| 962 | }; | ||
| 963 | |||
| 964 | document.addEventListener("keydown", handler); | ||
| 965 | return () => document.removeEventListener("keydown", handler); | ||
| 966 | }, [search]); | ||
| 967 | |||
| 968 | return ( | ||
| 969 | <> | ||
| 970 | <div className="flex w-72 shrink-0 flex-col border-r border-border bg-card/30 lg:w-80 xl:w-[22rem]"> | ||
| 971 | <div className="shrink-0 border-b border-border px-3 py-2"> | ||
| 972 | <div className="flex h-8 items-center gap-2 rounded-md border border-input bg-background px-2.5"> | ||
| 973 | <IconSearch className="size-3.5 shrink-0 text-muted-foreground/40" /> | ||
| 974 | <input | ||
| 975 | ref={searchRef} | ||
| 976 | value={search} | ||
| 977 | onChange={(event) => setSearch(event.target.value)} | ||
| 978 | placeholder={`Filter ${props.bundle.toolCount} tools...`} | ||
| 979 | className="min-w-0 flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground/40" | ||
| 980 | /> | ||
| 981 | {search.length > 0 ? ( | ||
| 982 | <button | ||
| 983 | type="button" | ||
| 984 | onClick={() => setSearch("")} | ||
| 985 | className="shrink-0 rounded p-0.5 text-muted-foreground/40 hover:text-foreground" | ||
| 986 | > | ||
| 987 | <IconClose /> | ||
| 988 | </button> | ||
| 989 | ) : ( | ||
| 990 | <kbd className="shrink-0 rounded border border-border bg-muted px-1 py-px text-[10px] leading-none text-muted-foreground/50"> | ||
| 991 | / | ||
| 992 | </kbd> | ||
| 993 | )} | ||
| 994 | </div> | ||
| 995 | </div> | ||
| 996 | |||
| 997 | <div className="flex-1 overflow-y-auto"> | ||
| 998 | {filteredTools.length === 0 ? ( | ||
| 999 | <div className="p-4 text-center text-[13px] text-muted-foreground/50"> | ||
| 1000 | {terms.length > 0 ? "No tools match your filter" : "No tools available"} | ||
| 1001 | </div> | ||
| 1002 | ) : ( | ||
| 1003 | <div className="p-1.5"> | ||
| 1004 | <GraphqlToolTree | ||
| 1005 | tools={filteredTools} | ||
| 1006 | selectedToolPath={props.selectedToolPath} | ||
| 1007 | onSelectTool={props.onSelectTool} | ||
| 1008 | search={search} | ||
| 1009 | isFiltered={terms.length > 0} | ||
| 1010 | sourceId={props.sourceId} | ||
| 1011 | /> | ||
| 1012 | </div> | ||
| 1013 | )} | ||
| 1014 | </div> | ||
| 1015 | </div> | ||
| 1016 | |||
| 1017 | <div className="flex min-w-0 flex-1 flex-col overflow-hidden"> | ||
| 1018 | <LoadableBlock loadable={props.detail} loading="Loading tool..."> | ||
| 1019 | {(detail) => | ||
| 1020 | detail ? ( | ||
| 1021 | <GraphqlToolDetailPanel detail={detail} /> | ||
| 1022 | ) : ( | ||
| 1023 | <EmptyState | ||
| 1024 | title={props.bundle.toolCount > 0 ? "Select a tool" : "No tools available"} | ||
| 1025 | description={ | ||
| 1026 | props.bundle.toolCount > 0 | ||
| 1027 | ? "Choose from the list or press / to search." | ||
| 1028 | : undefined | ||
| 1029 | } | ||
| 1030 | /> | ||
| 1031 | ) | ||
| 1032 | } | ||
| 1033 | </LoadableBlock> | ||
| 1034 | </div> | ||
| 1035 | </> | ||
| 1036 | ); | ||
| 1037 | } | ||
| 1038 | |||
| 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; | ||
| 1286 | const detailGroup = | 866 | const detailGroup = |
| 1287 | props.detail.summary.group | 867 | detail.summary.group |
| 1288 | && props.detail.summary.group.toLowerCase() !== props.detail.summary.method?.toLowerCase() | 868 | && detail.summary.group.toLowerCase() !== detail.summary.method?.toLowerCase() |
| 1289 | ? props.detail.summary.group | 869 | ? detail.summary.group |
| 1290 | : null; | 870 | : null; |
| 1291 | 871 | ||
| 1292 | return ( | ||
| 1293 | <div className="flex h-full flex-col overflow-hidden"> | ||
| 1294 | <div className="shrink-0 border-b border-border bg-background/95 backdrop-blur-sm"> | ||
| 1295 | <div className="flex items-start gap-3 px-5 py-3.5"> | ||
| 1296 | <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"> | ||
| 1297 | <IconTool className="size-4" /> | ||
| 1298 | </div> | ||
| 1299 | <div className="min-w-0 flex-1"> | ||
| 1300 | <div className="flex items-center gap-2"> | ||
| 1301 | <h3 className="truncate text-sm font-semibold text-foreground"> | ||
| 1302 | {props.detail.summary.path} | ||
| 1303 | </h3> | ||
| 1304 | <CopyButton | ||
| 1305 | text={props.detail.summary.path} | ||
| 1306 | field="path" | ||
| 1307 | copiedField={copiedField} | ||
| 1308 | onCopy={async (text, field) => { | ||
| 1309 | await navigator.clipboard.writeText(text); | ||
| 1310 | setCopiedField(field); | ||
| 1311 | window.setTimeout(() => setCopiedField(null), 1500); | ||
| 1312 | }} | ||
| 1313 | /> | ||
| 1314 | </div> | ||
| 1315 | <div className="mt-1 flex flex-wrap items-center gap-1.5"> | ||
| 1316 | {props.detail.summary.method && ( | ||
| 1317 | <MethodBadge method={props.detail.summary.method} /> | ||
| 1318 | )} | ||
| 1319 | {detailGroup && ( | ||
| 1320 | <Badge variant="outline">{detailGroup}</Badge> | ||
| 1321 | )} | ||
| 1322 | {props.detail.summary.protocol && ( | ||
| 1323 | <Badge variant="muted">{props.detail.summary.protocol}</Badge> | ||
| 1324 | )} | ||
| 1325 | </div> | ||
| 1326 | </div> | ||
| 1327 | </div> | ||
| 1328 | </div> | ||
| 1329 | |||
| 1330 | <div className="flex-1 overflow-y-auto"> | ||
| 1331 | <div className="space-y-4 px-5 py-4"> | ||
| 1332 | {props.detail.summary.description && ( | ||
| 1333 | <Markdown>{props.detail.summary.description}</Markdown> | ||
| 1334 | )} | ||
| 1335 | |||
| 1336 | <div className="grid grid-cols-1 gap-3 xl:grid-cols-2"> | ||
| 1337 | <DocumentPanel title="Input" body={inputType} lang="typescript" empty="No input." /> | ||
| 1338 | <DocumentPanel title="Output" body={outputType} lang="typescript" empty="No output." /> | ||
| 1339 | </div> | ||
| 1340 | |||
| 1341 | <DocumentPanel | ||
| 1342 | title="Call Signature" | ||
| 1343 | body={props.detail.contract.callSignature} | ||
| 1344 | lang="typescript" | ||
| 1345 | empty="No call signature." | ||
| 1346 | /> | ||
| 1347 | |||
| 1348 | <div className="grid grid-cols-1 gap-2 xl:grid-cols-2"> | ||
| 1349 | <DocumentPanel | ||
| 1350 | title="Input schema" | ||
| 1351 | body={props.detail.contract.input.schemaJson} | ||
| 1352 | empty="No input schema." | ||
| 1353 | compact | ||
| 1354 | /> | ||
| 1355 | <DocumentPanel | ||
| 1356 | title="Output schema" | ||
| 1357 | body={props.detail.contract.output.schemaJson} | ||
| 1358 | empty="No output schema." | ||
| 1359 | compact | ||
| 1360 | /> | ||
| 1361 | </div> | ||
| 1362 | |||
| 1363 | {props.detail.sections.map((section, index) => ( | ||
| 1364 | <section | ||
| 1365 | key={`${section.title}-${String(index)}`} | ||
| 1366 | className="overflow-hidden rounded-lg border border-border bg-card/60" | ||
| 1367 | > | ||
| 1368 | <div className="border-b border-border px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70"> | ||
| 1369 | {section.title} | ||
| 1370 | </div> | ||
| 1371 | {section.kind === "facts" ? ( | ||
| 1372 | <div className="grid gap-2 p-4"> | ||
| 1373 | {section.items.map((item) => ( | ||
| 1374 | <div key={`${section.title}-${item.label}`} className="text-sm"> | ||
| 1375 | <span className="text-muted-foreground">{item.label}:</span>{" "} | ||
| 1376 | <span className={item.mono ? "font-mono text-xs text-foreground" : "text-foreground"}> | ||
| 1377 | {item.value} | ||
| 1378 | </span> | ||
| 1379 | </div> | ||
| 1380 | ))} | ||
| 1381 | </div> | ||
| 1382 | ) : section.kind === "markdown" ? ( | ||
| 1383 | <div className="p-4"> | ||
| 1384 | <Markdown>{section.body}</Markdown> | ||
| 1385 | </div> | ||
| 1386 | ) : ( | ||
| 1387 | <DocumentPanel | ||
| 1388 | title={section.title} | ||
| 1389 | body={section.body} | ||
| 1390 | lang={section.language} | ||
| 1391 | empty="" | ||
| 1392 | /> | ||
| 1393 | )} | ||
| 1394 | </section> | ||
| 1395 | ))} | ||
| 1396 | </div> | ||
| 1397 | </div> | ||
| 1398 | </div> | ||
| 1399 | ); | ||
| 1400 | } | ||
| 1401 | |||
| 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 | return ( | 872 | return ( |
| 1570 | <> | 873 | <> |
| 1571 | {parts.map((part, index) => | 874 | {detailGroup && <Badge variant="outline">{detailGroup}</Badge>} |
| 1572 | part.highlighted ? ( | 875 | {detail.summary.protocol && ( |
| 1573 | <mark key={index} className="rounded-sm bg-primary/20 px-px text-foreground"> | 876 | <Badge variant="muted">{detail.summary.protocol}</Badge> |
| 1574 | {part.text} | ||
| 1575 | </mark> | ||
| 1576 | ) : ( | ||
| 1577 | <span key={index}>{part.text}</span> | ||
| 1578 | ), | ||
| 1579 | )} | 877 | )} |
| 1580 | </> | 878 | </> |
| 1581 | ); | 879 | ); |
| 1582 | } | 880 | }; |
| 1583 | 881 | ||
| 1584 | function GraphqlDetailPage(props: { | 882 | export function GraphqlDetailPage(props: { |
| 1585 | source: Source; | 883 | source: Source; |
| 1586 | }) { | 884 | }) { |
| 1587 | return ( | 885 | return ( |
| @@ -1592,7 +890,7 @@ function GraphqlDetailPage(props: { · 4 unmodified lines | |||
| 1592 | ); | 890 | ); |
| 1593 | } | 891 | } |
| 1594 | 892 | ||
| 1595 | function GraphqlToolDetailPage(props: { | 893 | export function GraphqlToolDetailPage(props: { |
| 1596 | source: Source; | 894 | source: Source; |
| 1597 | }) { | 895 | }) { |
| 1598 | const params = useSourcePluginRouteParams<GraphqlToolRouteParams>(); | 896 | const params = useSourcePluginRouteParams<GraphqlToolRouteParams>(); |
| @@ -1639,7 +937,7 @@ function GraphqlSourceRoute(props: { · 40 unmodified lines | |||
| 1639 | return props.children(source.data); | 937 | return props.children(source.data); |
| 1640 | } | 938 | } |
| 1641 | 939 | ||
| 1642 | function GraphqlDetailRoute() { | 940 | export function GraphqlDetailRoute() { |
| 1643 | return ( | 941 | return ( |
| 1644 | <GraphqlSourceRoute> | 942 | <GraphqlSourceRoute> |
| 1645 | {(source) => <GraphqlDetailPage source={source} />} | 943 | {(source) => <GraphqlDetailPage source={source} />} |
| @@ -1647,7 +945,7 @@ function GraphqlDetailRoute() { · 1 unmodified line | |||
| 1647 | ); | 945 | ); |
| 1648 | } | 946 | } |
| 1649 | 947 | ||
| 1650 | function GraphqlEditRoute() { | 948 | export function GraphqlEditRoute() { |
| 1651 | return ( | 949 | return ( |
| 1652 | <GraphqlSourceRoute> | 950 | <GraphqlSourceRoute> |
| 1653 | {(source) => <GraphqlEditPage source={source} />} | 951 | {(source) => <GraphqlEditPage source={source} />} |
| @@ -1655,38 +953,10 @@ function GraphqlEditRoute() { · 1 unmodified line | |||
| 1655 | ); | 953 | ); |
| 1656 | } | 954 | } |
| 1657 | 955 | ||
| 1658 | function GraphqlToolDetailRoute() { | 956 | export function GraphqlToolDetailRoute() { |
| 1659 | return ( | 957 | return ( |
| 1660 | <GraphqlSourceRoute> | 958 | <GraphqlSourceRoute> |
| 1661 | {(source) => <GraphqlToolDetailPage source={source} />} | 959 | {(source) => <GraphqlToolDetailPage source={source} />} |
| 1662 | </GraphqlSourceRoute> | 960 | </GraphqlSourceRoute> |
| 1663 | ); | 961 | ); |
| 1664 | } | 962 | } |
| 1665 | |||
| 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 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | 5 | "exports": { |
| 6 | ".": "./index.tsx" | 6 | ".": "./index.ts" |
| 7 | }, | 7 | }, |
| 8 | "files": [ | 8 | "files": [ |
| 9 | "index.tsx" | 9 | "components.tsx", |
| 10 | "index.ts" | ||
| 10 | ], | 11 | ], |
| 11 | "scripts": { | 12 | "scripts": { |
| 12 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | 13 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" |
| @@ -11,6 +11,7 @@ · 10 unmodified lines | |||
| 11 | "types": ["bun-types", "node"] | 11 | "types": ["bun-types", "node"] |
| 12 | }, | 12 | }, |
| 13 | "include": [ | 13 | "include": [ |
| 14 | "index.tsx" | 14 | "*.ts", |
| 15 | "*.tsx" | ||
| 15 | ] | 16 | ] |
| 16 | } | 17 | } |
| @@ -10,12 +10,12 @@ import { · 9 unmodified lines | |||
| 10 | useSource, | 10 | useSource, |
| 11 | } from "@executor/react"; | 11 | } from "@executor/react"; |
| 12 | import { | 12 | import { |
| 13 | Badge, | ||
| 13 | IconCheck, | 14 | IconCheck, |
| 14 | IconPencil, | 15 | IconPencil, |
| 15 | IconSearch, | 16 | IconSearch, |
| 16 | IconSpinner, | 17 | IconSpinner, |
| 17 | SourceToolExplorer, | 18 | SourceToolExplorer, |
| 18 | defineExecutorFrontendPlugin, | ||
| 19 | parseSourceToolExplorerSearch, | 19 | parseSourceToolExplorerSearch, |
| 20 | type SourceToolExplorerSearch, | 20 | type SourceToolExplorerSearch, |
| 21 | useSourcePluginNavigation, | 21 | useSourcePluginNavigation, |
| @@ -355,7 +355,14 @@ const openOauthPopup = (): Window | null => { · 333 unmodified lines | |||
| 355 | <main style="font-family: system-ui, -apple-system, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; color: #333;"> | 355 | <main style="font-family: system-ui, -apple-system, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; color: #333;"> |
| 356 | <div style="width: 20px; height: 20px; border: 2px solid #e5e7eb; border-top-color: #6366f1; border-radius: 50%; animation: spin 0.7s linear infinite; margin-bottom: 16px;"></div> | 356 | <div style="width: 20px; height: 20px; border: 2px solid #e5e7eb; border-top-color: #6366f1; border-radius: 50%; animation: spin 0.7s linear infinite; margin-bottom: 16px;"></div> |
| 357 | <p style="margin: 0; font-size: 14px; color: #888;">Redirecting to sign in…</p> | 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 | </main> | 366 | </main> |
| 360 | `; | 367 | `; |
| 361 | } catch { | 368 | } catch { |
| @@ -973,7 +980,7 @@ function McpSourceForm(props: { · 611 unmodified lines | |||
| 973 | ); | 980 | ); |
| 974 | } | 981 | } |
| 975 | 982 | ||
| 976 | function McpAddPage() { | 983 | export function McpAddPage() { |
| 977 | const navigation = useSourcePluginNavigation(); | 984 | const navigation = useSourcePluginNavigation(); |
| 978 | const initialValue = mcpInputFromSearch(useSourcePluginSearch()); | 985 | const initialValue = mcpInputFromSearch(useSourcePluginSearch()); |
| 979 | const installation = useLocalInstallation(); | 986 | const installation = useLocalInstallation(); |
| @@ -1012,7 +1019,7 @@ function McpAddPage() { · 32 unmodified lines | |||
| 1012 | ); | 1019 | ); |
| 1013 | } | 1020 | } |
| 1014 | 1021 | ||
| 1015 | function McpEditPage(props: { | 1022 | export function McpEditPage(props: { |
| 1016 | source: Source; | 1023 | source: Source; |
| 1017 | }) { | 1024 | }) { |
| 1018 | const navigation = useSourcePluginNavigation(); | 1025 | const navigation = useSourcePluginNavigation(); |
| @@ -1085,7 +1092,7 @@ function McpEditPage(props: { · 66 unmodified lines | |||
| 1085 | ); | 1092 | ); |
| 1086 | } | 1093 | } |
| 1087 | 1094 | ||
| 1088 | function McpDetailPage(props: { | 1095 | export function McpDetailPage(props: { |
| 1089 | source: Source; | 1096 | source: Source; |
| 1090 | }) { | 1097 | }) { |
| 1091 | const navigation = useSourcePluginNavigation(); | 1098 | const navigation = useSourcePluginNavigation(); |
| @@ -1127,14 +1134,10 @@ function McpDetailPage(props: { · 35 unmodified lines | |||
| 1127 | : config.endpoint ?? "remote"; | 1134 | : config.endpoint ?? "remote"; |
| 1128 | 1135 | ||
| 1129 | return ( | 1136 | return ( |
| 1130 | <div className="space-y-1"> | 1137 | <div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground"> |
| 1131 | <div className="font-mono text-xs text-foreground">{location}</div> | 1138 | <span className="font-mono text-foreground">{location}</span> |
| 1132 | <div> | 1139 | <Badge variant="muted">Transport: {config.transport ?? "auto"}</Badge> |
| 1133 | Transport: <span className="text-foreground">{config.transport ?? "auto"}</span> | 1140 | <Badge variant="muted">Auth: {config.auth.kind}</Badge> |
| 1134 | </div> | ||
| 1135 | <div> | ||
| 1136 | Auth: <span className="text-foreground">{config.auth.kind}</span> | ||
| 1137 | </div> | ||
| 1138 | </div> | 1141 | </div> |
| 1139 | ); | 1142 | ); |
| 1140 | }, [configResult]); | 1143 | }, [configResult]); |
| @@ -1265,7 +1268,7 @@ function McpSourceRoute(props: { · 124 unmodified lines | |||
| 1265 | return props.children(source.data); | 1268 | return props.children(source.data); |
| 1266 | } | 1269 | } |
| 1267 | 1270 | ||
| 1268 | function McpEditRoute() { | 1271 | export function McpEditRoute() { |
| 1269 | return ( | 1272 | return ( |
| 1270 | <McpSourceRoute> | 1273 | <McpSourceRoute> |
| 1271 | {(source) => <McpEditPage source={source} />} | 1274 | {(source) => <McpEditPage source={source} />} |
| @@ -1273,33 +1276,10 @@ function McpEditRoute() { · 1 unmodified line | |||
| 1273 | ); | 1276 | ); |
| 1274 | } | 1277 | } |
| 1275 | 1278 | ||
| 1276 | function McpDetailRoute() { | 1279 | export function McpDetailRoute() { |
| 1277 | return ( | 1280 | return ( |
| 1278 | <McpSourceRoute> | 1281 | <McpSourceRoute> |
| 1279 | {(source) => <McpDetailPage source={source} />} | 1282 | {(source) => <McpDetailPage source={source} />} |
| 1280 | </McpSourceRoute> | 1283 | </McpSourceRoute> |
| 1281 | ); | 1284 | ); |
| 1282 | } | 1285 | } |
| 1283 | |||
| 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 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | 5 | "exports": { |
| 6 | ".": "./index.tsx" | 6 | ".": "./index.ts" |
| 7 | }, | 7 | }, |
| 8 | "files": [ | 8 | "files": [ |
| 9 | "index.tsx" | 9 | "components.tsx", |
| 10 | "index.ts" | ||
| 10 | ], | 11 | ], |
| 11 | "scripts": { | 12 | "scripts": { |
| 12 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | 13 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" |
| @@ -11,6 +11,7 @@ · 10 unmodified lines | |||
| 11 | "types": ["bun-types", "node"] | 11 | "types": ["bun-types", "node"] |
| 12 | }, | 12 | }, |
| 13 | "include": [ | 13 | "include": [ |
| 14 | "index.tsx" | 14 | "*.ts", |
| 15 | "*.tsx" | ||
| 15 | ] | 16 | ] |
| 16 | } | 17 | } |
| @@ -1,7 +1,5 @@ | |||
| 1 | import type { | 1 | import type { |
| 2 | Loadable, | ||
| 3 | Source, | 2 | Source, |
| 4 | SourceInspection, | ||
| 5 | SourceInspectionToolDetail, | 3 | SourceInspectionToolDetail, |
| 6 | } from "@executor/react"; | 4 | } from "@executor/react"; |
| 7 | import { | 5 | import { |
| @@ -11,7 +9,6 @@ import { · 3 unmodified lines | |||
| 11 | useAtomSet, | 9 | useAtomSet, |
| 12 | useCreateSecret, | 10 | useCreateSecret, |
| 13 | useExecutorMutation, | 11 | useExecutorMutation, |
| 14 | usePrefetchToolDetail, | ||
| 15 | useSecrets, | 12 | useSecrets, |
| 16 | useSource, | 13 | useSource, |
| 17 | useSourceDiscovery, | 14 | useSourceDiscovery, |
| @@ -22,20 +19,12 @@ import { · 4 unmodified lines | |||
| 22 | import { | 19 | import { |
| 23 | Badge, | 20 | Badge, |
| 24 | DocumentPanel, | 21 | DocumentPanel, |
| 25 | EmptyState, | ||
| 26 | IconCheck, | ||
| 27 | IconChevron, | ||
| 28 | IconClose, | ||
| 29 | IconCopy, | ||
| 30 | IconFolder, | ||
| 31 | IconPencil, | 22 | IconPencil, |
| 32 | IconSearch, | ||
| 33 | IconTool, | ||
| 34 | LoadableBlock, | 23 | LoadableBlock, |
| 35 | Markdown, | 24 | SourceToolDetailPanel, |
| 36 | MethodBadge, | 25 | SourceToolDiscoveryPanel, |
| 26 | SourceToolModelWorkbench, | ||
| 37 | cn, | 27 | cn, |
| 38 | defineExecutorFrontendPlugin, | ||
| 39 | parseSourceToolExplorerSearch, | 28 | parseSourceToolExplorerSearch, |
| 40 | type SourceToolExplorerSearch, | 29 | type SourceToolExplorerSearch, |
| 41 | useSourcePluginNavigation, | 30 | useSourcePluginNavigation, |
| @@ -168,49 +157,6 @@ const Section = (props: { · 126 unmodified lines | |||
| 168 | </section> | 157 | </section> |
| 169 | ); | 158 | ); |
| 170 | 159 | ||
| 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 | const isPreviewableSpecUrl = (value: string): boolean => { | 160 | const isPreviewableSpecUrl = (value: string): boolean => { |
| 215 | const trimmed = value.trim(); | 161 | const trimmed = value.trim(); |
| 216 | if (!/^https?:\/\//i.test(trimmed)) { | 162 | if (!/^https?:\/\//i.test(trimmed)) { |
| @@ -740,7 +686,7 @@ function OpenApiSourceForm(props: { · 523 unmodified lines | |||
| 740 | ); | 686 | ); |
| 741 | } | 687 | } |
| 742 | 688 | ||
| 743 | function OpenApiAddSourcePage() { | 689 | export function OpenApiAddSourcePage() { |
| 744 | const navigation = useSourcePluginNavigation(); | 690 | const navigation = useSourcePluginNavigation(); |
| 745 | const initialValue = openApiInputFromSearch(useSourcePluginSearch()); | 691 | const initialValue = openApiInputFromSearch(useSourcePluginSearch()); |
| 746 | const openApiHttpClient = getOpenApiHttpClient(); | 692 | const openApiHttpClient = getOpenApiHttpClient(); |
| @@ -775,7 +721,7 @@ function OpenApiAddSourcePage() { · 28 unmodified lines | |||
| 775 | ); | 721 | ); |
| 776 | } | 722 | } |
| 777 | 723 | ||
| 778 | function OpenApiEditSourcePage(props: { | 724 | export function OpenApiEditSourcePage(props: { |
| 779 | source: Source; | 725 | source: Source; |
| 780 | }) { | 726 | }) { |
| 781 | const navigation = useSourcePluginNavigation(); | 727 | const navigation = useSourcePluginNavigation(); |
| @@ -848,7 +794,7 @@ function OpenApiEditSourcePage(props: { · 66 unmodified lines | |||
| 848 | ); | 794 | ); |
| 849 | } | 795 | } |
| 850 | 796 | ||
| 851 | function OpenApiSourceDetailPage(props: { | 797 | export function OpenApiSourceDetailPage(props: { |
| 852 | source: Source; | 798 | source: Source; |
| 853 | }) { | 799 | }) { |
| 854 | const navigation = useSourcePluginNavigation(); | 800 | const navigation = useSourcePluginNavigation(); |
| @@ -1041,7 +987,7 @@ function OpenApiSourceDetailPage(props: { · 186 unmodified lines | |||
| 1041 | 987 | ||
| 1042 | <div className="flex flex-1 min-h-0 overflow-hidden"> | 988 | <div className="flex flex-1 min-h-0 overflow-hidden"> |
| 1043 | {tab === "model" ? ( | 989 | {tab === "model" ? ( |
| 1044 | <ModelView | 990 | <SourceToolModelWorkbench |
| 1045 | bundle={loadedInspection} | 991 | bundle={loadedInspection} |
| 1046 | detail={toolDetail} | 992 | detail={toolDetail} |
| 1047 | selectedToolPath={selectedTool?.path ?? null} | 993 | selectedToolPath={selectedTool?.path ?? null} |
| @@ -1051,10 +997,16 @@ function OpenApiSourceDetailPage(props: { · 3 unmodified lines | |||
| 1051 | tool: toolPath, | 997 | tool: toolPath, |
| 1052 | })} | 998 | })} |
| 1053 | sourceId={props.source.id} | 999 | sourceId={props.source.id} |
| 1000 | renderDetail={(detail) => ( | ||
| 1001 | <SourceToolDetailPanel | ||
| 1002 | detail={detail} | ||
| 1003 | renderHeaderMeta={renderOpenApiToolHeaderMeta} | ||
| 1004 | renderSchemaExtras={renderOpenApiToolSchemaExtras} | ||
| 1005 | /> | ||
| 1006 | )} | ||
| 1054 | /> | 1007 | /> |
| 1055 | ) : ( | 1008 | ) : ( |
| 1056 | <DiscoveryView | 1009 | <SourceToolDiscoveryPanel |
| 1057 | bundle={loadedInspection} | ||
| 1058 | initialQuery={query} | 1010 | initialQuery={query} |
| 1059 | discovery={discovery} | 1011 | discovery={discovery} |
| 1060 | onSubmitQuery={(nextQuery) => | 1012 | onSubmitQuery={(nextQuery) => |
| @@ -1078,586 +1030,23 @@ function OpenApiSourceDetailPage(props: { · 17 unmodified lines | |||
| 1078 | ); | 1030 | ); |
| 1079 | } | 1031 | } |
| 1080 | 1032 | ||
| 1081 | function ModelView(props: { | 1033 | const renderOpenApiToolHeaderMeta = (detail: SourceInspectionToolDetail) => |
| 1082 | bundle: SourceInspection; | 1034 | detail.summary.pathTemplate ? ( |
| 1083 | detail: Loadable<SourceInspectionToolDetail | null>; | 1035 | <span className="font-mono text-[11px] text-muted-foreground/60"> |
| 1084 | selectedToolPath: string | null; | 1036 | {detail.summary.pathTemplate} |
| 1085 | onSelectTool: (toolPath: string) => void; | 1037 | </span> |
| 1086 | sourceId: string; | 1038 | ) : null; |
| 1087 | }) { | 1039 | |
| 1088 | const [search, setSearch] = useState(""); | 1040 | const renderOpenApiToolSchemaExtras = (detail: SourceInspectionToolDetail) => ( |
| 1089 | const searchRef = useRef<HTMLInputElement>(null); | 1041 | <> |
| 1090 | const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean); | 1042 | {detail.contract.input.exampleJson && ( |
| 1091 | 1043 | <DocumentPanel title="Example request" body={detail.contract.input.exampleJson} empty="" compact /> | |
| 1092 | const filteredTools = props.bundle.tools.filter((tool) => { | 1044 | )} |
| 1093 | if (terms.length === 0) return true; | 1045 | {detail.contract.output.exampleJson && ( |
| 1094 | const corpus = [ | 1046 | <DocumentPanel title="Example response" body={detail.contract.output.exampleJson} empty="" compact /> |
| 1095 | tool.path, | 1047 | )} |
| 1096 | tool.method ?? "", | 1048 | </> |
| 1097 | tool.inputTypePreview ?? "", | 1049 | ); |
| 1098 | tool.outputTypePreview ?? "", | ||
| 1099 | ] | ||
| 1100 | .join(" ") | ||
| 1101 | .toLowerCase(); | ||
| 1102 | return terms.every((term) => corpus.includes(term)); | ||
| 1103 | }); | ||
| 1104 | |||
| 1105 | useEffect(() => { | ||
| 1106 | const handler = (event: KeyboardEvent) => { | ||
| 1107 | if (event.key === "/" && document.activeElement?.tagName !== "INPUT") { | ||
| 1108 | event.preventDefault(); | ||
| 1109 | searchRef.current?.focus(); | ||
| 1110 | } | ||
| 1111 | if (event.key === "Escape") { | ||
| 1112 | searchRef.current?.blur(); | ||
| 1113 | if (search.length > 0) setSearch(""); | ||
| 1114 | } | ||
| 1115 | }; | ||
| 1116 | document.addEventListener("keydown", handler); | ||
| 1117 | return () => document.removeEventListener("keydown", handler); | ||
| 1118 | }, [search]); | ||
| 1119 | |||
| 1120 | return ( | ||
| 1121 | <> | ||
| 1122 | <div className="flex w-72 shrink-0 flex-col border-r border-border bg-card/30 lg:w-80 xl:w-[22rem]"> | ||
| 1123 | <div className="shrink-0 border-b border-border px-3 py-2"> | ||
| 1124 | <div className="flex h-8 items-center gap-2 rounded-md border border-input bg-background px-2.5"> | ||
| 1125 | <IconSearch className="size-3.5 shrink-0 text-muted-foreground/40" /> | ||
| 1126 | <input | ||
| 1127 | ref={searchRef} | ||
| 1128 | value={search} | ||
| 1129 | onChange={(event) => setSearch(event.target.value)} | ||
| 1130 | placeholder={`Filter ${props.bundle.toolCount} tools…`} | ||
| 1131 | className="min-w-0 flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground/40" | ||
| 1132 | /> | ||
| 1133 | {search.length > 0 ? ( | ||
| 1134 | <button | ||
| 1135 | type="button" | ||
| 1136 | onClick={() => setSearch("")} | ||
| 1137 | className="shrink-0 rounded p-0.5 text-muted-foreground/40 hover:text-foreground" | ||
| 1138 | > | ||
| 1139 | <IconClose /> | ||
| 1140 | </button> | ||
| 1141 | ) : ( | ||
| 1142 | <kbd className="shrink-0 rounded border border-border bg-muted px-1 py-px text-[10px] leading-none text-muted-foreground/50"> | ||
| 1143 | / | ||
| 1144 | </kbd> | ||
| 1145 | )} | ||
| 1146 | </div> | ||
| 1147 | </div> | ||
| 1148 | |||
| 1149 | <div className="flex-1 overflow-y-auto"> | ||
| 1150 | {filteredTools.length === 0 ? ( | ||
| 1151 | <div className="p-4 text-center text-[13px] text-muted-foreground/50"> | ||
| 1152 | {terms.length > 0 ? "No tools match your filter" : "No tools available"} | ||
| 1153 | </div> | ||
| 1154 | ) : ( | ||
| 1155 | <div className="p-1.5"> | ||
| 1156 | <ToolTree | ||
| 1157 | tools={filteredTools} | ||
| 1158 | selectedToolPath={props.selectedToolPath} | ||
| 1159 | onSelectTool={props.onSelectTool} | ||
| 1160 | search={search} | ||
| 1161 | isFiltered={terms.length > 0} | ||
| 1162 | sourceId={props.sourceId} | ||
| 1163 | /> | ||
| 1164 | </div> | ||
| 1165 | )} | ||
| 1166 | </div> | ||
| 1167 | </div> | ||
| 1168 | |||
| 1169 | <div className="flex min-w-0 flex-1 flex-col overflow-hidden"> | ||
| 1170 | <LoadableBlock loadable={props.detail} loading="Loading tool..."> | ||
| 1171 | {(detail) => | ||
| 1172 | detail ? ( | ||
| 1173 | <ToolDetailPanel detail={detail} /> | ||
| 1174 | ) : ( | ||
| 1175 | <EmptyState | ||
| 1176 | title={props.bundle.toolCount > 0 ? "Select a tool" : "No tools available"} | ||
| 1177 | description={props.bundle.toolCount > 0 ? "Choose from the list or press / to search" : undefined} | ||
| 1178 | /> | ||
| 1179 | ) | ||
| 1180 | } | ||
| 1181 | </LoadableBlock> | ||
| 1182 | </div> | ||
| 1183 | </> | ||
| 1184 | ); | ||
| 1185 | } | ||
| 1186 | |||
| 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 | } | ||
| 1661 | 1050 | ||
| 1662 | function OpenApiSourceRoute(props: { | 1051 | function OpenApiSourceRoute(props: { |
| 1663 | children: (source: Source) => ReactNode; | 1052 | children: (source: Source) => ReactNode; |
| @@ -1693,7 +1082,7 @@ function OpenApiSourceRoute(props: { · 29 unmodified lines | |||
| 1693 | return props.children(source.data); | 1082 | return props.children(source.data); |
| 1694 | } | 1083 | } |
| 1695 | 1084 | ||
| 1696 | function OpenApiDetailRoute() { | 1085 | export function OpenApiDetailRoute() { |
| 1697 | return ( | 1086 | return ( |
| 1698 | <OpenApiSourceRoute> | 1087 | <OpenApiSourceRoute> |
| 1699 | {(source) => <OpenApiSourceDetailPage source={source} />} | 1088 | {(source) => <OpenApiSourceDetailPage source={source} />} |
| @@ -1701,32 +1090,10 @@ function OpenApiDetailRoute() { · 1 unmodified line | |||
| 1701 | ); | 1090 | ); |
| 1702 | } | 1091 | } |
| 1703 | 1092 | ||
| 1704 | function OpenApiEditRoute() { | 1093 | export function OpenApiEditRoute() { |
| 1705 | return ( | 1094 | return ( |
| 1706 | <OpenApiSourceRoute> | 1095 | <OpenApiSourceRoute> |
| 1707 | {(source) => <OpenApiEditSourcePage source={source} />} | 1096 | {(source) => <OpenApiEditSourcePage source={source} />} |
| 1708 | </OpenApiSourceRoute> | 1097 | </OpenApiSourceRoute> |
| 1709 | ); | 1098 | ); |
| 1710 | } | 1099 | } |
| 1711 | |||
| 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 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | 5 | "exports": { |
| 6 | ".": "./index.tsx" | 6 | ".": "./index.ts" |
| 7 | }, | 7 | }, |
| 8 | "files": [ | 8 | "files": [ |
| 9 | "index.tsx" | 9 | "components.tsx", |
| 10 | "index.ts" | ||
| 10 | ], | 11 | ], |
| 11 | "scripts": { | 12 | "scripts": { |
| 12 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | 13 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" |
| @@ -11,6 +11,7 @@ · 10 unmodified lines | |||
| 11 | "types": ["bun-types", "node"] | 11 | "types": ["bun-types", "node"] |
| 12 | }, | 12 | }, |
| 13 | "include": [ | 13 | "include": [ |
| 14 | "index.tsx" | 14 | "*.ts", |
| 15 | "*.tsx" | ||
| 15 | ] | 16 | ] |
| 16 | } | 17 | } |