Unified · Split
| @@ -2,7 +2,17 @@ · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import { Suspense, useState } from "react"; | 3 | import { Suspense, useState } from "react"; |
| 4 | import { Link, Navigate, useLocation } from "react-router"; | 4 | import { Link, Navigate, useLocation } from "react-router"; |
| 5 | import { ExternalLink, Github, LayoutDashboard, ListTodo, ShieldCheck, Wrench, Menu, X } from "lucide-react"; | 5 | import { |
| 6 | ExternalLink, | ||
| 7 | Github, | ||
| 8 | LayoutDashboard, | ||
| 9 | ListTodo, | ||
| 10 | MessageCircle, | ||
| 11 | Menu, | ||
| 12 | ShieldCheck, | ||
| 13 | Wrench, | ||
| 14 | X, | ||
| 15 | } from "lucide-react"; | ||
| 6 | import { Button } from "@/components/ui/button"; | 16 | import { Button } from "@/components/ui/button"; |
| 7 | import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet"; | 17 | import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet"; |
| 8 | import { useSession } from "@/lib/session-context"; | 18 | import { useSession } from "@/lib/session-context"; |
| @@ -21,6 +31,8 @@ const NAV_ITEMS = [ · 12 unmodified lines | |||
| 21 | ]; | 31 | ]; |
| 22 | 32 | ||
| 23 | const EXECUTOR_REPO_URL = "https://github.com/RhysSullivan/executor"; | 33 | const EXECUTOR_REPO_URL = "https://github.com/RhysSullivan/executor"; |
| 34 | const TWITTER_FEEDBACK_URL = | ||
| 35 | "https://twitter.com/intent/tweet?text=%40rhyssullivan%20Executor%20feedback:%20"; | ||
| 24 | 36 | ||
| 25 | function NavLinks({ onClick }: { onClick?: () => void }) { | 37 | function NavLinks({ onClick }: { onClick?: () => void }) { |
| 26 | const location = useLocation(); | 38 | const location = useLocation(); |
| @@ -66,7 +78,24 @@ function RepoStarLink() { · 39 unmodified lines | |||
| 66 | <Github className="h-3.5 w-3.5" /> | 78 | <Github className="h-3.5 w-3.5" /> |
| 67 | Star Executor | 79 | Star Executor |
| 68 | </span> | 80 | </span> |
| 69 | <ExternalLink className="h-3.5 w-3.5 opacity-70 transition-opacity group-hover:opacity-100" /> | 81 | <ExternalLink className="h-3.5 w-3.5 opacity-0 transition-opacity group-hover:opacity-100" /> |
| 82 | </a> | ||
| 83 | ); | ||
| 84 | } | ||
| 85 | |||
| 86 | function FeedbackLink() { | ||
| 87 | return ( | ||
| 88 | <a | ||
| 89 | href={TWITTER_FEEDBACK_URL} | ||
| 90 | target="_blank" | ||
| 91 | rel="noreferrer" | ||
| 92 | className="group flex items-center justify-between px-1 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground transition-colors hover:text-foreground" | ||
| 93 | > | ||
| 94 | <span className="flex items-center gap-1.5"> | ||
| 95 | <MessageCircle className="h-3.5 w-3.5" /> | ||
| 96 | Feedback | ||
| 97 | </span> | ||
| 98 | <ExternalLink className="h-3.5 w-3.5 opacity-0 transition-opacity group-hover:opacity-100" /> | ||
| 70 | </a> | 99 | </a> |
| 71 | ); | 100 | ); |
| 72 | } | 101 | } |
| @@ -85,6 +114,9 @@ function Sidebar() { · 12 unmodified lines | |||
| 85 | <SessionInfo /> | 114 | <SessionInfo /> |
| 86 | </Suspense> | 115 | </Suspense> |
| 87 | </div> | 116 | </div> |
| 117 | <div className="px-3 pb-1"> | ||
| 118 | <FeedbackLink /> | ||
| 119 | </div> | ||
| 88 | <div className="px-3 pb-1"> | 120 | <div className="px-3 pb-1"> |
| 89 | <RepoStarLink /> | 121 | <RepoStarLink /> |
| 90 | </div> | 122 | </div> |
| @@ -122,6 +154,9 @@ function MobileHeader() { · 31 unmodified lines | |||
| 122 | <SessionInfo /> | 154 | <SessionInfo /> |
| 123 | </Suspense> | 155 | </Suspense> |
| 124 | </div> | 156 | </div> |
| 157 | <div className="px-3 pb-1"> | ||
| 158 | <FeedbackLink /> | ||
| 159 | </div> | ||
| 125 | <div className="px-3 pb-1"> | 160 | <div className="px-3 pb-1"> |
| 126 | <RepoStarLink /> | 161 | <RepoStarLink /> |
| 127 | </div> | 162 | </div> |
| @@ -178,8 +178,10 @@ export function CodeEditor({ · 177 unmodified lines | |||
| 178 | onMount={handleMount} | 178 | onMount={handleMount} |
| 179 | options={CODE_EDITOR_OPTIONS} | 179 | options={CODE_EDITOR_OPTIONS} |
| 180 | loading={ | 180 | loading={ |
| 181 | <div className="flex h-full items-center justify-center bg-background text-xs font-mono text-muted-foreground"> | 181 | <div className="flex h-full w-full items-center justify-center bg-background text-xs font-mono text-muted-foreground"> |
| 182 | Loading editor... | 182 | <span className="w-full max-w-xs rounded-md border border-border/80 bg-muted/70 px-3 py-1 text-center"> |
| 183 | Loading editor... | ||
| 184 | </span> | ||
| 183 | </div> | 185 | </div> |
| 184 | } | 186 | } |
| 185 | /> | 187 | /> |
| @@ -89,14 +89,14 @@ export function CatalogViewSection({ · 88 unmodified lines | |||
| 89 | <img | 89 | <img |
| 90 | src={item.logoUrl} | 90 | src={item.logoUrl} |
| 91 | alt="" | 91 | alt="" |
| 92 | className="w-5 h-5 rounded shrink-0 mt-0.5 object-contain" | 92 | className="w-5 h-5 rounded-full shrink-0 mt-0.5 object-cover" |
| 93 | /> | 93 | /> |
| 94 | ) : ( | 94 | ) : ( |
| 95 | <SourceFavicon | 95 | <SourceFavicon |
| 96 | sourceUrl={item.originUrl || item.specUrl} | 96 | sourceUrl={item.originUrl || item.specUrl} |
| 97 | fallbackType={item.sourceType ?? "openapi"} | 97 | fallbackType={item.sourceType ?? "openapi"} |
| 98 | iconClassName="h-5 w-5 text-muted-foreground" | 98 | iconClassName="h-5 w-5 text-muted-foreground" |
| 99 | imageClassName="w-5 h-5 rounded object-contain" | 99 | imageClassName="w-5 h-5" |
| 100 | /> | 100 | /> |
| 101 | )} | 101 | )} |
| 102 | <div className="flex-1 min-w-0 w-0 overflow-hidden"> | 102 | <div className="flex-1 min-w-0 w-0 overflow-hidden"> |
| @@ -1,13 +1,13 @@ | |||
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { useState } from "react"; | 3 | import { type ChangeEvent, useEffect, useState } from "react"; |
| 4 | import { useAction } from "convex/react"; | 4 | import { useAction } from "convex/react"; |
| 5 | import { toast } from "sonner"; | 5 | import { toast } from "sonner"; |
| 6 | import { Button } from "@/components/ui/button"; | 6 | import { Button } from "@/components/ui/button"; |
| 7 | import { Input } from "@/components/ui/input"; | 7 | import { Input } from "@/components/ui/input"; |
| 8 | import { Label } from "@/components/ui/label"; | 8 | import { Label } from "@/components/ui/label"; |
| 9 | import { Textarea } from "@/components/ui/textarea"; | ||
| 10 | import { Badge } from "@/components/ui/badge"; | 9 | import { Badge } from "@/components/ui/badge"; |
| 10 | import { Plus, Trash2 } from "lucide-react"; | ||
| 11 | import { | 11 | import { |
| 12 | Select, | 12 | Select, |
| 13 | SelectContent, | 13 | SelectContent, |
| @@ -30,7 +30,10 @@ import type { · 16 unmodified lines | |||
| 30 | } from "@/lib/types"; | 30 | } from "@/lib/types"; |
| 31 | import { | 31 | import { |
| 32 | connectionDisplayName, | 32 | connectionDisplayName, |
| 33 | parseHeaderOverrides, | 33 | parseHeaderOverrideEntries, |
| 34 | parseHeaderOverrideRows, | ||
| 35 | serializeHeaderOverrideRows, | ||
| 36 | type HeaderOverrideEntry, | ||
| 34 | } from "@/lib/credentials/source-helpers"; | 37 | } from "@/lib/credentials/source-helpers"; |
| 35 | import { sourceForCredentialKey } from "@/lib/tools/source-helpers"; | 38 | import { sourceForCredentialKey } from "@/lib/tools/source-helpers"; |
| 36 | import { ConnectionAuthFields } from "./auth-fields"; | 39 | import { ConnectionAuthFields } from "./auth-fields"; |
| @@ -64,6 +67,7 @@ export function ConnectionFormDialog({ · 27 unmodified lines | |||
| 64 | const { context, clientConfig } = useSession(); | 67 | const { context, clientConfig } = useSession(); |
| 65 | const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential); | 68 | const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential); |
| 66 | const [saving, setSaving] = useState(false); | 69 | const [saving, setSaving] = useState(false); |
| 70 | const [headerRows, setHeaderRows] = useState<HeaderOverrideEntry[]>(() => parseHeaderOverrideEntries("")); | ||
| 67 | const { | 71 | const { |
| 68 | sourceKey, | 72 | sourceKey, |
| 69 | scopeType, | 73 | scopeType, |
| @@ -116,6 +120,14 @@ export function ConnectionFormDialog({ · 46 unmodified lines | |||
| 116 | && (selectedSourceName ? loadingSourceNames.includes(selectedSourceName) : false); | 120 | && (selectedSourceName ? loadingSourceNames.includes(selectedSourceName) : false); |
| 117 | const detectedAuthLabel = authDetectionPending ? "Detecting..." : authBadge; | 121 | const detectedAuthLabel = authDetectionPending ? "Detecting..." : authBadge; |
| 118 | 122 | ||
| 123 | useEffect(() => { | ||
| 124 | if (!open) { | ||
| 125 | return; | ||
| 126 | } | ||
| 127 | |||
| 128 | setHeaderRows(parseHeaderOverrideEntries(customHeadersText)); | ||
| 129 | }, [open, customHeadersText]); | ||
| 130 | |||
| 119 | const handleSave = async () => { | 131 | const handleSave = async () => { |
| 120 | if (!context) { | 132 | if (!context) { |
| 121 | return; | 133 | return; |
| @@ -129,12 +141,15 @@ export function ConnectionFormDialog({ · 7 unmodified lines | |||
| 129 | return; | 141 | return; |
| 130 | } | 142 | } |
| 131 | 143 | ||
| 132 | const parsedHeaders = parseHeaderOverrides(customHeadersText); | 144 | const parsedHeaders = parseHeaderOverrideRows(headerRows); |
| 133 | if (!parsedHeaders.value) { | 145 | if (!parsedHeaders.value) { |
| 134 | toast.error(parsedHeaders.error ?? "Invalid header overrides"); | 146 | toast.error(parsedHeaders.error ?? "Invalid header overrides"); |
| 135 | return; | 147 | return; |
| 136 | } | 148 | } |
| 137 | 149 | ||
| 150 | const serializedHeaders = serializeHeaderOverrideRows(headerRows); | ||
| 151 | setCustomHeadersText(serializedHeaders); | ||
| 152 | |||
| 138 | const linkExisting = !editing && connectionMode === "existing"; | 153 | const linkExisting = !editing && connectionMode === "existing"; |
| 139 | if (linkExisting && !existingConnectionKey) { | 154 | if (linkExisting && !existingConnectionKey) { |
| 140 | toast.error("Select saved credentials"); | 155 | toast.error("Select saved credentials"); |
| @@ -205,6 +220,31 @@ export function ConnectionFormDialog({ · 64 unmodified lines | |||
| 205 | } | 220 | } |
| 206 | }; | 221 | }; |
| 207 | 222 | ||
| 223 | const addHeaderRow = () => { | ||
| 224 | setHeaderRows((current) => [...current, { key: "", value: "" }]); | ||
| 225 | }; | ||
| 226 | |||
| 227 | const removeHeaderRow = (index: number) => { | ||
| 228 | setHeaderRows((current) => { | ||
| 229 | const next = current.filter((_, rowIndex) => rowIndex !== index); | ||
| 230 | return next.length === 0 ? [{ key: "", value: "" }] : next; | ||
| 231 | }); | ||
| 232 | }; | ||
| 233 | |||
| 234 | const updateHeaderRow = (index: number, patch: Partial<HeaderOverrideEntry>) => { | ||
| 235 | setHeaderRows((current) => | ||
| 236 | current.map((entry, rowIndex) => { | ||
| 237 | if (rowIndex !== index) { | ||
| 238 | return entry; | ||
| 239 | } | ||
| 240 | return { | ||
| 241 | ...entry, | ||
| 242 | ...patch, | ||
| 243 | }; | ||
| 244 | }), | ||
| 245 | ); | ||
| 246 | }; | ||
| 247 | |||
| 208 | return ( | 248 | return ( |
| 209 | <Dialog open={open} onOpenChange={onOpenChange}> | 249 | <Dialog open={open} onOpenChange={onOpenChange}> |
| 210 | <DialogContent className="bg-card border-border sm:max-w-lg"> | 250 | <DialogContent className="bg-card border-border sm:max-w-lg"> |
| @@ -345,15 +385,49 @@ export function ConnectionFormDialog({ · 134 unmodified lines | |||
| 345 | onBasicPasswordChange={setBasicPassword} | 385 | onBasicPasswordChange={setBasicPassword} |
| 346 | /> | 386 | /> |
| 347 | 387 | ||
| 348 | <div className="space-y-1.5"> | 388 | <div className="space-y-2"> |
| 349 | <Label className="text-xs text-muted-foreground">Extra Headers (optional)</Label> | 389 | <div className="flex items-center justify-between gap-2"> |
| 350 | <Textarea | 390 | <Label className="text-xs text-muted-foreground">Extra Headers (optional)</Label> |
| 351 | value={customHeadersText} | 391 | <Button type="button" variant="outline" size="sm" onClick={addHeaderRow} className="h-7 px-2 text-[11px]"> |
| 352 | onChange={(e) => setCustomHeadersText(e.target.value)} | 392 | <Plus className="h-3.5 w-3.5 mr-1.5" /> |
| 353 | rows={4} | 393 | Add header |
| 354 | placeholder="x-tenant-id: acme\nx-env: staging" | 394 | </Button> |
| 355 | className="text-xs font-mono bg-background" | 395 | </div> |
| 356 | /> | 396 | <div className="space-y-2"> |
| 397 | {headerRows.map((headerRow, index) => ( | ||
| 398 | <div key={`${headerRow.key || "header"}-${index}`} className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2 items-start"> | ||
| 399 | <Input | ||
| 400 | value={headerRow.key} | ||
| 401 | onChange={(event: ChangeEvent<HTMLInputElement>) => | ||
| 402 | updateHeaderRow(index, { key: event.target.value }) | ||
| 403 | } | ||
| 404 | placeholder="Header" | ||
| 405 | className="h-8 text-xs font-mono bg-background" | ||
| 406 | /> | ||
| 407 | <Input | ||
| 408 | value={headerRow.value} | ||
| 409 | onChange={(event: ChangeEvent<HTMLInputElement>) => | ||
| 410 | updateHeaderRow(index, { value: event.target.value }) | ||
| 411 | } | ||
| 412 | placeholder="Value" | ||
| 413 | className="h-8 text-xs font-mono bg-background" | ||
| 414 | /> | ||
| 415 | <Button | ||
| 416 | type="button" | ||
| 417 | variant="outline" | ||
| 418 | size="sm" | ||
| 419 | className="h-8 px-2" | ||
| 420 | disabled={headerRows.length === 1} | ||
| 421 | onClick={() => void removeHeaderRow(index)} | ||
| 422 | > | ||
| 423 | <Trash2 className="h-3.5 w-3.5" /> | ||
| 424 | </Button> | ||
| 425 | </div> | ||
| 426 | ))} | ||
| 427 | </div> | ||
| 428 | <p className="text-[10px] text-muted-foreground"> | ||
| 429 | Add one `Header` and `Value` pair per line. | ||
| 430 | </p> | ||
| 357 | </div> | 431 | </div> |
| 358 | 432 | ||
| 359 | <Button onClick={handleSave} disabled={saving || authDetectionPending} className="w-full h-9" size="sm"> | 433 | <Button onClick={handleSave} disabled={saving || authDetectionPending} className="w-full h-9" size="sm"> |
| @@ -115,13 +115,13 @@ export function GroupNode({ · 114 unmodified lines | |||
| 115 | 115 | ||
| 116 | {isSource && ( | 116 | {isSource && ( |
| 117 | <div className="h-5 w-5 rounded bg-muted/60 flex items-center justify-center shrink-0"> | 117 | <div className="h-5 w-5 rounded bg-muted/60 flex items-center justify-center shrink-0"> |
| 118 | {source ? ( | 118 | {source ? ( |
| 119 | <SourceFavicon | 119 | <SourceFavicon |
| 120 | source={source} | 120 | source={source} |
| 121 | iconClassName="h-3 w-3 text-muted-foreground" | 121 | iconClassName="h-3 w-3 text-muted-foreground" |
| 122 | imageClassName="w-full h-full object-contain" | 122 | imageClassName="w-full h-full" |
| 123 | /> | 123 | /> |
| 124 | ) : ( | 124 | ) : ( |
| 125 | sourceTypeFallback === "mcp" | 125 | sourceTypeFallback === "mcp" |
| 126 | ? <Server className="h-3 w-3 text-muted-foreground" /> | 126 | ? <Server className="h-3 w-3 text-muted-foreground" /> |
| 127 | : sourceTypeFallback === "graphql" | 127 | : sourceTypeFallback === "graphql" |
| @@ -259,39 +259,18 @@ export function SourceSidebar({ · 131 unmodified lines | |||
| 259 | ); | 259 | ); |
| 260 | 260 | ||
| 261 | const groups = useMemo(() => { | 261 | const groups = useMemo(() => { |
| 262 | const map = new Map< | 262 | return sources |
| 263 | string, | 263 | .filter((source) => source.enabled) |
| 264 | { name: string; type: string; count: number; isLoading: boolean; warningCount: number; source?: ToolSourceRecord } | 264 | .map((source) => ({ |
| 265 | >(); | ||
| 266 | |||
| 267 | for (const source of sources) { | ||
| 268 | if (!source.enabled) { | ||
| 269 | continue; | ||
| 270 | } | ||
| 271 | |||
| 272 | const count = sourceCounts[source.name] ?? 0; | ||
| 273 | const isLoading = loadingSources.has(source.name); | ||
| 274 | const warningCount = warningCountsBySource[source.name] ?? 0; | ||
| 275 | |||
| 276 | if (count === 0 && !isLoading && warningCount === 0) { | ||
| 277 | continue; | ||
| 278 | } | ||
| 279 | |||
| 280 | map.set(source.name, { | ||
| 281 | name: source.name, | 265 | name: source.name, |
| 282 | type: source.type, | 266 | type: source.type, |
| 283 | count, | 267 | count: sourceCounts[source.name] ?? 0, |
| 284 | isLoading, | 268 | isLoading: loadingSources.has(source.name), |
| 285 | warningCount, | 269 | warningCount: warningCountsBySource[source.name] ?? 0, |
| 286 | source, | 270 | source, |
| 287 | }); | 271 | })) |
| 288 | } | 272 | .sort((a, b) => a.name.localeCompare(b.name)); |
| 289 | 273 | }, [loadingSources, sourceCounts, sources, warningCountsBySource]); | |
| 290 | return Array.from(map.values()).sort((a, b) => { | ||
| 291 | if (a.count !== b.count) return b.count - a.count; | ||
| 292 | return a.name.localeCompare(b.name); | ||
| 293 | }); | ||
| 294 | }, [sourceCounts, sources, loadingSources, warningCountsBySource]); | ||
| 295 | 274 | ||
| 296 | return ( | 275 | return ( |
| 297 | <div className="w-52 shrink-0 border-r border-border/50 pr-0 hidden lg:block"> | 276 | <div className="w-52 shrink-0 border-r border-border/50 pr-0 hidden lg:block"> |
| @@ -302,6 +281,9 @@ export function SourceSidebar({ · 4 unmodified lines | |||
| 302 | </div> | 281 | </div> |
| 303 | 282 | ||
| 304 | <div className="space-y-0.5 px-1"> | 283 | <div className="space-y-0.5 px-1"> |
| 284 | {groups.length === 0 ? ( | ||
| 285 | <p className="px-2 py-2 text-[11px] text-muted-foreground/60">No sources configured yet.</p> | ||
| 286 | ) : null} | ||
| 305 | {groups.map((g) => { | 287 | {groups.map((g) => { |
| 306 | const editMeta = g.source ? sourceDialogMeta?.[g.name] : undefined; | 288 | const editMeta = g.source ? sourceDialogMeta?.[g.name] : undefined; |
| 307 | return ( | 289 | return ( |
| @@ -53,8 +53,6 @@ interface ToolExplorerToolbarProps { · 52 unmodified lines | |||
| 53 | export function ToolExplorerToolbar({ | 53 | export function ToolExplorerToolbar({ |
| 54 | search, | 54 | search, |
| 55 | filteredToolCount, | 55 | filteredToolCount, |
| 56 | hasSearch, | ||
| 57 | resultCount, | ||
| 58 | loadingInventory = false, | 56 | loadingInventory = false, |
| 59 | viewMode, | 57 | viewMode, |
| 60 | groupBy, | 58 | groupBy, |
| @@ -72,8 +70,6 @@ export function ToolExplorerToolbar({ · 11 unmodified lines | |||
| 72 | addSourceAction, | 70 | addSourceAction, |
| 73 | onSelectAll, | 71 | onSelectAll, |
| 74 | onClearSelection, | 72 | onClearSelection, |
| 75 | onExpandAll, | ||
| 76 | onCollapseAll, | ||
| 77 | }: ToolExplorerToolbarProps) { | 73 | }: ToolExplorerToolbarProps) { |
| 78 | return ( | 74 | return ( |
| 79 | <div className="shrink-0"> | 75 | <div className="shrink-0"> |
| @@ -286,34 +282,6 @@ export function ToolExplorerToolbar({ · 206 unmodified lines | |||
| 286 | </Button> | 282 | </Button> |
| 287 | </div> | 283 | </div> |
| 288 | )} | 284 | )} |
| 289 | |||
| 290 | <div className="flex items-center justify-end pb-1.5"> | ||
| 291 | {hasSearch ? ( | ||
| 292 | <span className="text-[10px] font-mono text-muted-foreground/50"> | ||
| 293 | {resultCount} results | ||
| 294 | {activeSource && ` in ${activeSource}`} | ||
| 295 | </span> | ||
| 296 | ) : loadingInventory ? ( | ||
| 297 | <span className="text-[10px] font-mono text-muted-foreground/50">Loading tool inventory...</span> | ||
| 298 | ) : null} | ||
| 299 | {viewMode === "tree" && ( | ||
| 300 | <div className="flex gap-1"> | ||
| 301 | <button | ||
| 302 | onClick={onExpandAll} | ||
| 303 | className="text-[10px] text-muted-foreground/50 hover:text-muted-foreground transition-colors" | ||
| 304 | > | ||
| 305 | Expand all | ||
| 306 | </button> | ||
| 307 | <span className="text-[10px] text-muted-foreground/30">·</span> | ||
| 308 | <button | ||
| 309 | onClick={onCollapseAll} | ||
| 310 | className="text-[10px] text-muted-foreground/50 hover:text-muted-foreground transition-colors" | ||
| 311 | > | ||
| 312 | Collapse all | ||
| 313 | </button> | ||
| 314 | </div> | ||
| 315 | )} | ||
| 316 | </div> | ||
| 317 | </div> | 285 | </div> |
| 318 | ); | 286 | ); |
| 319 | } | 287 | } |
| @@ -1,6 +1,6 @@ | |||
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { type ReactNode, useState, useMemo, useCallback, useRef, useDeferredValue, useEffect } from "react"; | 3 | import { type ReactNode, useState, useMemo, useCallback, useRef, useDeferredValue } from "react"; |
| 4 | import { cn } from "@/lib/utils"; | 4 | import { cn } from "@/lib/utils"; |
| 5 | import { | 5 | import { |
| 6 | collectGroupKeys, | 6 | collectGroupKeys, |
| @@ -142,50 +142,16 @@ export function ToolExplorer({ · 135 unmodified lines | |||
| 142 | return counts; | 142 | return counts; |
| 143 | }, [hydratedTools]); | 143 | }, [hydratedTools]); |
| 144 | 144 | ||
| 145 | const [stableSources, setStableSources] = useState<ToolSourceRecord[]>(sources); | 145 | const visibleSources = useMemo(() => { |
| 146 | 146 | const enabledByName = new Map<string, ToolSourceRecord>(); | |
| 147 | useEffect(() => { | 147 | for (const source of sources) { |
| 148 | setStableSources((previous) => { | 148 | if (source.enabled) { |
| 149 | const nextByName = new Map<string, ToolSourceRecord>(); | 149 | enabledByName.set(source.name, source); |
| 150 | |||
| 151 | for (const source of previous) { | ||
| 152 | if (source.enabled) { | ||
| 153 | nextByName.set(source.name, source); | ||
| 154 | } | ||
| 155 | } | ||
| 156 | |||
| 157 | const enabledSourceNames = new Set<string>(); | ||
| 158 | for (const source of sources) { | ||
| 159 | if (!source.enabled) { | ||
| 160 | continue; | ||
| 161 | } | ||
| 162 | enabledSourceNames.add(source.name); | ||
| 163 | nextByName.set(source.name, source); | ||
| 164 | } | ||
| 165 | |||
| 166 | for (const [name] of nextByName) { | ||
| 167 | const hasTools = (sourceCounts[name] ?? 0) > 0; | ||
| 168 | const isLoading = loadingSourceSet.has(name); | ||
| 169 | const stillEnabled = enabledSourceNames.has(name); | ||
| 170 | if (!stillEnabled && !hasTools && !isLoading) { | ||
| 171 | nextByName.delete(name); | ||
| 172 | } | ||
| 173 | } | 150 | } |
| 151 | } | ||
| 174 | 152 | ||
| 175 | const next = Array.from(nextByName.values()).sort((a, b) => a.name.localeCompare(b.name)); | 153 | return Array.from(enabledByName.values()).sort((a, b) => a.name.localeCompare(b.name)); |
| 176 | const unchanged = | 154 | }, [sources]); |
| 177 | next.length === previous.length | ||
| 178 | && next.every((source, index) => { | ||
| 179 | const prior = previous[index]; | ||
| 180 | return prior | ||
| 181 | && prior.id === source.id | ||
| 182 | && prior.name === source.name | ||
| 183 | && prior.type === source.type | ||
| 184 | && prior.enabled === source.enabled; | ||
| 185 | }); | ||
| 186 | return unchanged ? previous : next; | ||
| 187 | }); | ||
| 188 | }, [loadingSourceSet, sourceCounts, sources]); | ||
| 189 | 155 | ||
| 190 | const searchedTools = useMemo(() => { | 156 | const searchedTools = useMemo(() => { |
| 191 | return filterToolsBySearch(filteredTools, search); | 157 | return filterToolsBySearch(filteredTools, search); |
| @@ -194,16 +160,16 @@ export function ToolExplorer({ · 2 unmodified lines | |||
| 194 | const warningsBySource = useMemo(() => warningsBySourceName(warnings), [warnings]); | 160 | const warningsBySource = useMemo(() => warningsBySourceName(warnings), [warnings]); |
| 195 | 161 | ||
| 196 | const sidebarExistingSourceNames = useMemo(() => { | 162 | const sidebarExistingSourceNames = useMemo(() => { |
| 197 | return existingSourceNames ?? new Set(stableSources.map((source) => source.name)); | 163 | return existingSourceNames ?? new Set(visibleSources.map((source) => source.name)); |
| 198 | }, [existingSourceNames, stableSources]); | 164 | }, [existingSourceNames, visibleSources]); |
| 199 | 165 | ||
| 200 | const treeGroups = useMemo(() => { | 166 | const treeGroups = useMemo(() => { |
| 201 | return treeGroupsForView(searchedTools, viewMode, groupBy, { | 167 | return treeGroupsForView(searchedTools, viewMode, groupBy, { |
| 202 | loadingSources: visibleLoadingSources, | 168 | loadingSources: visibleLoadingSources, |
| 203 | sourceRecords: stableSources, | 169 | sourceRecords: visibleSources, |
| 204 | activeSource: resolvedActiveSource, | 170 | activeSource: resolvedActiveSource, |
| 205 | }); | 171 | }); |
| 206 | }, [searchedTools, viewMode, groupBy, visibleLoadingSources, stableSources, resolvedActiveSource]); | 172 | }, [searchedTools, viewMode, groupBy, visibleLoadingSources, visibleSources, resolvedActiveSource]); |
| 207 | 173 | ||
| 208 | const flatTools = useMemo(() => { | 174 | const flatTools = useMemo(() => { |
| 209 | return flatToolsForView(searchedTools, viewMode); | 175 | return flatToolsForView(searchedTools, viewMode); |
| @@ -211,11 +177,11 @@ export function ToolExplorer({ · 1 unmodified line | |||
| 211 | 177 | ||
| 212 | const sourceByName = useMemo(() => { | 178 | const sourceByName = useMemo(() => { |
| 213 | const map = new Map<string, ToolSourceRecord>(); | 179 | const map = new Map<string, ToolSourceRecord>(); |
| 214 | for (const source of stableSources) { | 180 | for (const source of visibleSources) { |
| 215 | map.set(source.name, source); | 181 | map.set(source.name, source); |
| 216 | } | 182 | } |
| 217 | return map; | 183 | return map; |
| 218 | }, [stableSources]); | 184 | }, [visibleSources]); |
| 219 | 185 | ||
| 220 | const autoExpandedKeys = useMemo(() => { | 186 | const autoExpandedKeys = useMemo(() => { |
| 221 | return autoExpandedKeysForSearch(search, filteredTools, viewMode); | 187 | return autoExpandedKeysForSearch(search, filteredTools, viewMode); |
| @@ -289,8 +255,14 @@ export function ToolExplorer({ · 67 unmodified lines | |||
| 289 | }, [selectedKeys, filteredTools]); | 255 | }, [selectedKeys, filteredTools]); |
| 290 | 256 | ||
| 291 | const sourceOptions = useMemo( | 257 | const sourceOptions = useMemo( |
| 292 | () => sourceOptionsFromTools(hydratedTools, loadingSources), | 258 | () => { |
| 293 | [hydratedTools, loadingSources], | 259 | const optionSet = new Set(sourceOptionsFromTools(hydratedTools, loadingSources)); |
| 260 | for (const source of visibleSources) { | ||
| 261 | optionSet.add(source.name); | ||
| 262 | } | ||
| 263 | return Array.from(optionSet).sort((a, b) => a.localeCompare(b)); | ||
| 264 | }, | ||
| 265 | [hydratedTools, loadingSources, visibleSources], | ||
| 294 | ); | 266 | ); |
| 295 | 267 | ||
| 296 | const maybeLoadToolDetails = useCallback(async (tool: ToolDescriptor, expanded: boolean) => { | 268 | const maybeLoadToolDetails = useCallback(async (tool: ToolDescriptor, expanded: boolean) => { |
| @@ -383,7 +355,7 @@ export function ToolExplorer({ · 86 unmodified lines | |||
| 383 | <div className="flex" onWheelCapture={handleExplorerWheel}> | 355 | <div className="flex" onWheelCapture={handleExplorerWheel}> |
| 384 | {showSourceSidebar ? ( | 356 | {showSourceSidebar ? ( |
| 385 | <SourceSidebar | 357 | <SourceSidebar |
| 386 | sources={stableSources} | 358 | sources={visibleSources} |
| 387 | sourceCounts={sourceCounts} | 359 | sourceCounts={sourceCounts} |
| 388 | loadingSources={loadingSourceSet} | 360 | loadingSources={loadingSourceSet} |
| 389 | warningsBySource={warningsBySource} | 361 | warningsBySource={warningsBySource} |
| @@ -56,17 +56,6 @@ export function ToolDetail({ · 55 unmodified lines | |||
| 56 | 56 | ||
| 57 | {inputHint && <TypeSignature raw={inputHint} label="Arguments" />} | 57 | {inputHint && <TypeSignature raw={inputHint} label="Arguments" />} |
| 58 | {outputHint && <TypeSignature raw={outputHint} label="Returns" />} | 58 | {outputHint && <TypeSignature raw={outputHint} label="Returns" />} |
| 59 | |||
| 60 | {required.length > 0 ? ( | ||
| 61 | <div className="space-y-1"> | ||
| 62 | <p className="text-[9px] font-mono uppercase tracking-wider text-muted-foreground/50"> | ||
| 63 | Required Keys | ||
| 64 | </p> | ||
| 65 | <p className="text-[11px] font-mono text-muted-foreground break-words"> | ||
| 66 | {required.join(", ")} | ||
| 67 | </p> | ||
| 68 | </div> | ||
| 69 | ) : null} | ||
| 70 | </div> | 59 | </div> |
| 71 | ); | 60 | ); |
| 72 | } | 61 | } |
| @@ -4,6 +4,7 @@ import { useState } from "react"; · 3 unmodified lines | |||
| 4 | import { Layers, Globe, Server } from "lucide-react"; | 4 | import { Layers, Globe, Server } from "lucide-react"; |
| 5 | import Image from "next/image"; | 5 | import Image from "next/image"; |
| 6 | import type { ToolSourceRecord } from "@/lib/types"; | 6 | import type { ToolSourceRecord } from "@/lib/types"; |
| 7 | import { cn } from "@/lib/utils"; | ||
| 7 | import { getSourceFavicon, getSourceFaviconUrl } from "@/lib/tools/source-helpers"; | 8 | import { getSourceFavicon, getSourceFaviconUrl } from "@/lib/tools/source-helpers"; |
| 8 | 9 | ||
| 9 | interface SourceFaviconProps { | 10 | interface SourceFaviconProps { |
| @@ -53,7 +54,7 @@ export function SourceFavicon({ · 43 unmodified lines | |||
| 53 | alt="" | 54 | alt="" |
| 54 | width={imageSize} | 55 | width={imageSize} |
| 55 | height={imageSize} | 56 | height={imageSize} |
| 56 | className={imageClassName ?? "w-full h-full object-contain"} | 57 | className={cn("w-full h-full object-cover rounded-full", imageClassName)} |
| 57 | loading="lazy" | 58 | loading="lazy" |
| 58 | unoptimized | 59 | unoptimized |
| 59 | onError={() => setFailedSrc(sourceFavicon)} | 60 | onError={() => setFailedSrc(sourceFavicon)} |
| @@ -1,6 +1,6 @@ | |||
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { useCallback, useMemo, useState } from "react"; | 3 | import { useCallback, useMemo, useRef, useState } from "react"; |
| 4 | import { | 4 | import { |
| 5 | Plus, | 5 | Plus, |
| 6 | } from "lucide-react"; | 6 | } from "lucide-react"; |
| @@ -24,6 +24,7 @@ import type { · 17 unmodified lines | |||
| 24 | CredentialRecord, | 24 | CredentialRecord, |
| 25 | } from "@/lib/types"; | 25 | } from "@/lib/types"; |
| 26 | import { | 26 | import { |
| 27 | parseWarningSourceName, | ||
| 27 | warningsBySourceName, | 28 | warningsBySourceName, |
| 28 | } from "@/lib/tools/source-helpers"; | 29 | } from "@/lib/tools/source-helpers"; |
| 29 | import { sourceLabel } from "@/lib/tool/source-utils"; | 30 | import { sourceLabel } from "@/lib/tool/source-utils"; |
| @@ -35,6 +36,7 @@ import type { SourceDialogMeta } from "@/components/tools/add/source-dialog"; · 5 unmodified lines | |||
| 35 | type OptimisticAdd = { kind: "add"; source: ToolSourceRecord; addedAt: number }; | 36 | type OptimisticAdd = { kind: "add"; source: ToolSourceRecord; addedAt: number }; |
| 36 | type OptimisticRemove = { kind: "remove"; sourceName: string; removedAt: number }; | 37 | type OptimisticRemove = { kind: "remove"; sourceName: string; removedAt: number }; |
| 37 | type OptimisticOp = OptimisticAdd | OptimisticRemove; | 38 | type OptimisticOp = OptimisticAdd | OptimisticRemove; |
| 39 | const OPTIMISTIC_ADD_TTL_MS = 45_000; | ||
| 38 | 40 | ||
| 39 | /** Merge server sources with pending optimistic operations. */ | 41 | /** Merge server sources with pending optimistic operations. */ |
| 40 | function applyOptimisticOps( | 42 | function applyOptimisticOps( |
| @@ -60,11 +62,19 @@ function applyOptimisticOps( · 19 unmodified lines | |||
| 60 | function pruneStaleOps( | 62 | function pruneStaleOps( |
| 61 | ops: OptimisticOp[], | 63 | ops: OptimisticOp[], |
| 62 | serverSources: ToolSourceRecord[], | 64 | serverSources: ToolSourceRecord[], |
| 65 | toolSourceNames: Set<string>, | ||
| 63 | ): OptimisticOp[] { | 66 | ): OptimisticOp[] { |
| 64 | const serverNames = new Set(serverSources.map((s) => s.name)); | 67 | const serverNames = new Set(serverSources.map((s) => s.name)); |
| 68 | const now = Date.now(); | ||
| 65 | return ops.filter((op) => { | 69 | return ops.filter((op) => { |
| 66 | if (op.kind === "add") { | 70 | if (op.kind === "add") { |
| 67 | return !serverNames.has(op.source.name); | 71 | if (toolSourceNames.has(op.source.name)) { |
| 72 | return false; | ||
| 73 | } | ||
| 74 | if (serverNames.has(op.source.name) && (now - op.addedAt) > OPTIMISTIC_ADD_TTL_MS) { | ||
| 75 | return false; | ||
| 76 | } | ||
| 77 | return true; | ||
| 68 | } | 78 | } |
| 69 | if (op.kind === "remove") { | 79 | if (op.kind === "remove") { |
| 70 | return serverNames.has(op.sourceName); | 80 | return serverNames.has(op.sourceName); |
| @@ -108,20 +118,49 @@ export function ToolsView({ · 37 unmodified lines | |||
| 108 | convexApi.workspace.listToolSources, | 118 | convexApi.workspace.listToolSources, |
| 109 | workspaceQueryArgs(context), | 119 | workspaceQueryArgs(context), |
| 110 | ); | 120 | ); |
| 121 | const sourceCacheRef = useRef<{ workspaceId: string | null; items: ToolSourceRecord[] }>({ | ||
| 122 | workspaceId: null, | ||
| 123 | items: [], | ||
| 124 | }); | ||
| 125 | const workspaceId = context?.workspaceId ?? null; | ||
| 126 | if (sourceCacheRef.current.workspaceId !== workspaceId) { | ||
| 127 | sourceCacheRef.current = { | ||
| 128 | workspaceId, | ||
| 129 | items: [], | ||
| 130 | }; | ||
| 131 | } | ||
| 132 | if (sources !== undefined) { | ||
| 133 | sourceCacheRef.current.items = sources; | ||
| 134 | } | ||
| 111 | const serverSourceItems = useMemo<ToolSourceRecord[]>( | 135 | const serverSourceItems = useMemo<ToolSourceRecord[]>( |
| 112 | () => sources ?? [], | 136 | () => sources ?? sourceCacheRef.current.items, |
| 113 | [sources], | 137 | [sources], |
| 114 | ); | 138 | ); |
| 115 | const sourcesLoading = !!context && sources === undefined; | 139 | const sourcesLoading = !!context && sources === undefined && serverSourceItems.length === 0; |
| 140 | |||
| 141 | const { | ||
| 142 | tools, | ||
| 143 | warnings, | ||
| 144 | sourceQuality, | ||
| 145 | sourceAuthProfiles, | ||
| 146 | loadingSources, | ||
| 147 | loadingTools, | ||
| 148 | refreshingTools, | ||
| 149 | loadToolDetails, | ||
| 150 | } = useWorkspaceTools(context ?? null, { includeDetails: false }); | ||
| 151 | |||
| 152 | const toolSourceNames = useMemo( | ||
| 153 | () => new Set(tools.map((tool) => sourceLabel(tool.source))), | ||
| 154 | [tools], | ||
| 155 | ); | ||
| 116 | 156 | ||
| 117 | // ── Optimistic source state ── | 157 | // ── Optimistic source state ── |
| 118 | const [rawOptimisticOps, setOptimisticOps] = useState<OptimisticOp[]>([]); | 158 | const [rawOptimisticOps, setOptimisticOps] = useState<OptimisticOp[]>([]); |
| 119 | 159 | ||
| 120 | // Prune stale ops: if the server already reflects an add/remove, drop it. | 160 | // Prune stale ops: if the server already reflects an add/remove, drop it. |
| 121 | // This is a pure derivation — no side effects, no refs. | ||
| 122 | const optimisticOps = useMemo( | 161 | const optimisticOps = useMemo( |
| 123 | () => pruneStaleOps(rawOptimisticOps, serverSourceItems), | 162 | () => pruneStaleOps(rawOptimisticOps, serverSourceItems, toolSourceNames), |
| 124 | [rawOptimisticOps, serverSourceItems], | 163 | [rawOptimisticOps, serverSourceItems, toolSourceNames], |
| 125 | ); | 164 | ); |
| 126 | 165 | ||
| 127 | const sourceItems = useMemo( | 166 | const sourceItems = useMemo( |
| @@ -144,33 +183,42 @@ export function ToolsView({ · 16 unmodified lines | |||
| 144 | const credentialItems: CredentialRecord[] = credentials ?? []; | 183 | const credentialItems: CredentialRecord[] = credentials ?? []; |
| 145 | const credentialsLoading = !!context && credentials === undefined; | 184 | const credentialsLoading = !!context && credentials === undefined; |
| 146 | 185 | ||
| 147 | const { | 186 | const globalWarnings = useMemo( |
| 148 | tools, | 187 | () => warnings.filter((warning) => !parseWarningSourceName(warning)), |
| 149 | warnings, | 188 | [warnings], |
| 150 | sourceQuality, | 189 | ); |
| 151 | sourceAuthProfiles, | 190 | const hasGlobalInventoryWarning = useMemo( |
| 152 | loadingSources, | 191 | () => globalWarnings.some((warning) => |
| 153 | loadingTools, | 192 | warning.includes("Tool inventory is still loading") |
| 154 | refreshingTools, | 193 | || warning.includes("showing previous results while refreshing"), |
| 155 | loadToolDetails, | 194 | ), |
| 156 | } = useWorkspaceTools(context ?? null, { includeDetails: false }); | 195 | [globalWarnings], |
| 196 | ); | ||
| 157 | 197 | ||
| 158 | // Merge optimistic loading with real loading sources | 198 | // Merge optimistic loading with real loading sources |
| 159 | const mergedLoadingSources = useMemo(() => { | 199 | const mergedLoadingSources = useMemo(() => { |
| 160 | const combined = [...loadingSources]; | 200 | const combined = [...loadingSources]; |
| 201 | |||
| 202 | if (hasGlobalInventoryWarning) { | ||
| 203 | for (const source of sourceItems) { | ||
| 204 | if (toolSourceNames.has(source.name)) { | ||
| 205 | continue; | ||
| 206 | } | ||
| 207 | if (!combined.includes(source.name)) { | ||
| 208 | combined.push(source.name); | ||
| 209 | } | ||
| 210 | } | ||
| 211 | } | ||
| 212 | |||
| 161 | for (const name of optimisticallyLoadingNames) { | 213 | for (const name of optimisticallyLoadingNames) { |
| 162 | if (!combined.includes(name)) { | 214 | if (!combined.includes(name)) { |
| 163 | combined.push(name); | 215 | combined.push(name); |
| 164 | } | 216 | } |
| 165 | } | 217 | } |
| 166 | return combined; | 218 | return combined; |
| 167 | }, [loadingSources, optimisticallyLoadingNames]); | 219 | }, [hasGlobalInventoryWarning, loadingSources, optimisticallyLoadingNames, sourceItems, toolSourceNames]); |
| 168 | 220 | ||
| 169 | const existingSourceNames = useMemo(() => new Set(sourceItems.map((source) => source.name)), [sourceItems]); | 221 | const existingSourceNames = useMemo(() => new Set(sourceItems.map((source) => source.name)), [sourceItems]); |
| 170 | const toolSourceNames = useMemo( | ||
| 171 | () => new Set(tools.map((tool) => sourceLabel(tool.source))), | ||
| 172 | [tools], | ||
| 173 | ); | ||
| 174 | const warningsBySource = useMemo(() => warningsBySourceName(warnings), [warnings]); | 222 | const warningsBySource = useMemo(() => warningsBySourceName(warnings), [warnings]); |
| 175 | const sourceDialogMeta = useMemo(() => { | 223 | const sourceDialogMeta = useMemo(() => { |
| 176 | const bySource: Record<string, SourceDialogMeta> = {}; | 224 | const bySource: Record<string, SourceDialogMeta> = {}; |
| @@ -300,6 +348,19 @@ export function ToolsView({ · 123 unmodified lines | |||
| 300 | <TabsContent value="catalog" className="mt-4 min-h-0"> | 348 | <TabsContent value="catalog" className="mt-4 min-h-0"> |
| 301 | <Card className="bg-card border-border min-h-0 flex flex-col pt-4 gap-3"> | 349 | <Card className="bg-card border-border min-h-0 flex flex-col pt-4 gap-3"> |
| 302 | <CardContent className="pt-0 min-h-0 flex-1 flex flex-col gap-3"> | 350 | <CardContent className="pt-0 min-h-0 flex-1 flex flex-col gap-3"> |
| 351 | {globalWarnings.length > 0 ? ( | ||
| 352 | <div className="rounded-md border border-terminal-amber/30 bg-terminal-amber/5 px-3 py-2"> | ||
| 353 | <p className="text-[11px] font-mono text-terminal-amber/90">Inventory status</p> | ||
| 354 | <div className="mt-1 space-y-1"> | ||
| 355 | {globalWarnings.map((warning, index) => ( | ||
| 356 | <p key={`global-warning-${index}`} className="text-[11px] leading-4 text-muted-foreground"> | ||
| 357 | {warning} | ||
| 358 | </p> | ||
| 359 | ))} | ||
| 360 | </div> | ||
| 361 | </div> | ||
| 362 | ) : null} | ||
| 363 | |||
| 303 | <div className="min-h-0 flex-1"> | 364 | <div className="min-h-0 flex-1"> |
| 304 | <ToolExplorer | 365 | <ToolExplorer |
| 305 | tools={tools} | 366 | tools={tools} |
| @@ -98,6 +98,68 @@ export function parseHeaderOverrides(text: string): { value?: Record<string, str · 97 unmodified lines | |||
| 98 | return { value: headers }; | 98 | return { value: headers }; |
| 99 | } | 99 | } |
| 100 | 100 | ||
| 101 | export type HeaderOverrideEntry = { | ||
| 102 | key: string; | ||
| 103 | value: string; | ||
| 104 | }; | ||
| 105 | |||
| 106 | export function parseHeaderOverrideEntries(text: string): HeaderOverrideEntry[] { | ||
| 107 | const rows = text | ||
| 108 | .split("\n") | ||
| 109 | .map((line) => line.trim()) | ||
| 110 | .filter((line) => line.length > 0); | ||
| 111 | |||
| 112 | if (rows.length === 0) { | ||
| 113 | return [{ key: "", value: "" }]; | ||
| 114 | } | ||
| 115 | |||
| 116 | return rows.map((row) => { | ||
| 117 | const separator = row.indexOf(":"); | ||
| 118 | if (separator <= 0) { | ||
| 119 | return { key: row.trim(), value: "" }; | ||
| 120 | } | ||
| 121 | |||
| 122 | return { | ||
| 123 | key: row.slice(0, separator).trim(), | ||
| 124 | value: row.slice(separator + 1).trim(), | ||
| 125 | }; | ||
| 126 | }); | ||
| 127 | } | ||
| 128 | |||
| 129 | export function parseHeaderOverrideRows(entries: HeaderOverrideEntry[]): { value?: Record<string, string>; error?: string } { | ||
| 130 | const headers: Record<string, string> = {}; | ||
| 131 | |||
| 132 | for (let index = 0; index < entries.length; index += 1) { | ||
| 133 | const rawKey = entries[index]?.key ?? ""; | ||
| 134 | const rawValue = entries[index]?.value ?? ""; | ||
| 135 | const key = rawKey.trim(); | ||
| 136 | const value = rawValue.trim(); | ||
| 137 | |||
| 138 | if (!key && !value) { | ||
| 139 | continue; | ||
| 140 | } | ||
| 141 | |||
| 142 | if (!key || !value) { | ||
| 143 | return { error: `Invalid header row ${index + 1}` }; | ||
| 144 | } | ||
| 145 | |||
| 146 | headers[key] = value; | ||
| 147 | } | ||
| 148 | |||
| 149 | return { value: headers }; | ||
| 150 | } | ||
| 151 | |||
| 152 | export function serializeHeaderOverrideRows(entries: HeaderOverrideEntry[]): string { | ||
| 153 | return entries | ||
| 154 | .map((entry) => ({ | ||
| 155 | key: entry.key.trim(), | ||
| 156 | value: entry.value.trim(), | ||
| 157 | })) | ||
| 158 | .filter((entry) => entry.key.length > 0 && entry.value.length > 0) | ||
| 159 | .map((entry) => `${entry.key}: ${entry.value}`) | ||
| 160 | .join("\n"); | ||
| 161 | } | ||
| 162 | |||
| 101 | export function formatHeaderOverrides(overrides: Record<string, unknown> | undefined): string { | 163 | export function formatHeaderOverrides(overrides: Record<string, unknown> | undefined): string { |
| 102 | const headers = overrides && typeof overrides.headers === "object" ? (overrides.headers as Record<string, unknown>) : {}; | 164 | const headers = overrides && typeof overrides.headers === "object" ? (overrides.headers as Record<string, unknown>) : {}; |
| 103 | return Object.entries(headers) | 165 | return Object.entries(headers) |