From a95270e37b155929c9a7f7d59e9a012f58957587 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Tue, 12 May 2026 13:09:32 +0530 Subject: [PATCH] fix: show preset icons for connected sources --- apps/cloud/src/web/shell.tsx | 6 +- apps/local/src/web/shell.tsx | 11 ++- .../react/src/components/command-palette.tsx | 4 +- .../src/components/source-favicon.test.tsx | 88 ++++++++++++++++++- .../react/src/components/source-favicon.tsx | 73 ++++++++++++++- packages/react/src/pages/sources.tsx | 9 +- 6 files changed, 178 insertions(+), 13 deletions(-) diff --git a/apps/cloud/src/web/shell.tsx b/apps/cloud/src/web/shell.tsx index 9c8ebf11b..cd97ea869 100644 --- a/apps/cloud/src/web/shell.tsx +++ b/apps/cloud/src/web/shell.tsx @@ -28,8 +28,9 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@executor-js/react/components/dropdown-menu"; -import { SourceFavicon } from "@executor-js/react/components/source-favicon"; +import { SourceFavicon, sourcePresetIconUrl } from "@executor-js/react/components/source-favicon"; import { CommandPalette } from "@executor-js/react/components/command-palette"; +import { useSourcePlugins } from "@executor-js/sdk/client"; import { authWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { AUTH_PATHS } from "../auth/api"; import { organizationsAtom, switchOrganization, useAuth } from "./auth"; @@ -75,6 +76,7 @@ function NavItem(props: { to: string; label: string; active: boolean; onNavigate function SourceList(props: { pathname: string; onNavigate?: () => void }) { const scopeId = useScope(); const sources = useAtomValue(sourcesOptimisticAtom(scopeId)); + const sourcePlugins = useSourcePlugins(); return AsyncResult.match(sources, { onInitial: () => ( @@ -114,7 +116,7 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) { : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground", ].join(" ")} > - + {s.name} {s.kind} diff --git a/apps/local/src/web/shell.tsx b/apps/local/src/web/shell.tsx index 5f6acf79f..b53e57284 100644 --- a/apps/local/src/web/shell.tsx +++ b/apps/local/src/web/shell.tsx @@ -7,9 +7,9 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { sourcesAtom, sourcesOptimisticAtom, toolsAtom } from "@executor-js/react/api/atoms"; import { useScope, useScopeInfo } from "@executor-js/react/api/scope-context"; import { Button } from "@executor-js/react/components/button"; -import { SourceFavicon } from "@executor-js/react/components/source-favicon"; +import { SourceFavicon, sourcePresetIconUrl } from "@executor-js/react/components/source-favicon"; import { CommandPalette } from "@executor-js/react/components/command-palette"; -import { useClientPlugins } from "@executor-js/sdk/client"; +import { useClientPlugins, useSourcePlugins } from "@executor-js/sdk/client"; // ── Env ───────────────────────────────────────────────────────────────── @@ -268,6 +268,7 @@ function PluginNav(props: { pathname: string; onNavigate?: () => void }) { function SourceList(props: { pathname: string; onNavigate?: () => void }) { const scopeId = useScope(); const sources = useAtomValue(sourcesOptimisticAtom(scopeId)); + const sourcePlugins = useSourcePlugins(); return AsyncResult.match(sources, { onInitial: () =>
Loading…
, @@ -298,7 +299,11 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) { : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground", ].join(" ")} > - + {s.name} {s.kind} diff --git a/packages/react/src/components/command-palette.tsx b/packages/react/src/components/command-palette.tsx index 07bcf64cd..6e0583f90 100644 --- a/packages/react/src/components/command-palette.tsx +++ b/packages/react/src/components/command-palette.tsx @@ -3,7 +3,7 @@ import { useNavigate } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { PlusIcon } from "lucide-react"; -import { SourceFavicon } from "./source-favicon"; +import { SourceFavicon, sourcePresetIconUrl } from "./source-favicon"; import { sourcesOptimisticAtom } from "../api/atoms"; import { useScope } from "../hooks/use-scope"; import { useSourcePlugins } from "@executor-js/sdk/client"; @@ -151,7 +151,7 @@ export function CommandPalette() { value={`connected ${s.name} ${s.id} ${s.kind}`} onSelect={() => goToSource(s.id)} > - + {s.name} {s.kind} diff --git a/packages/react/src/components/source-favicon.test.tsx b/packages/react/src/components/source-favicon.test.tsx index 58010cc0e..0ac8f2a73 100644 --- a/packages/react/src/components/source-favicon.test.tsx +++ b/packages/react/src/components/source-favicon.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { sourceFaviconUrl, sourceLocalIconUrl } from "./source-favicon"; +import { sourceFaviconUrl, sourceLocalIconUrl, sourcePresetIconUrl } from "./source-favicon"; describe("SourceFavicon", () => { it("uses the favicon service that handles provider-specific icon locations", () => { @@ -24,4 +24,90 @@ describe("SourceFavicon", () => { expect(sourceLocalIconUrl("executor")).toBe("/favicon-32.png"); expect(sourceLocalIconUrl("openapi")).toBeNull(); }); + + it("finds preset icons from a source URL", () => { + expect( + sourcePresetIconUrl( + { + id: "google_sheets", + kind: "googleDiscovery", + name: "Google Sheets API", + url: "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest", + }, + [ + { + key: "googleDiscovery", + label: "Google Discovery", + add: () => null, + edit: () => null, + presets: [ + { + id: "google-sheets", + name: "Google Sheets", + summary: "Spreadsheets.", + url: "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest", + icon: "https://example.com/sheets.svg", + }, + ], + }, + ], + ), + ).toBe("https://example.com/sheets.svg"); + }); + + it("finds preset icons from display names with suffixes", () => { + expect( + sourcePresetIconUrl( + { + id: "google_search_console_api", + kind: "googleDiscovery", + name: "Google Search Console API", + }, + [ + { + key: "googleDiscovery", + label: "Google Discovery", + add: () => null, + edit: () => null, + presets: [ + { + id: "google-search-console", + name: "Google Search Console", + summary: "Search performance.", + icon: "https://example.com/google.svg", + }, + ], + }, + ], + ), + ).toBe("https://example.com/google.svg"); + }); + + it("finds preset icons from a source id when the URL is missing", () => { + expect( + sourcePresetIconUrl( + { + id: "sentry", + kind: "mcp", + name: "Sentry MCP", + }, + [ + { + key: "mcp", + label: "MCP", + add: () => null, + edit: () => null, + presets: [ + { + id: "sentry", + name: "Sentry", + summary: "Errors.", + icon: "https://example.com/sentry.png", + }, + ], + }, + ], + ), + ).toBe("https://example.com/sentry.png"); + }); }); diff --git a/packages/react/src/components/source-favicon.tsx b/packages/react/src/components/source-favicon.tsx index e03f04587..51f7bbf1c 100644 --- a/packages/react/src/components/source-favicon.tsx +++ b/packages/react/src/components/source-favicon.tsx @@ -1,5 +1,6 @@ import { BoxIcon } from "lucide-react"; import { useState } from "react"; +import type { SourcePlugin } from "@executor-js/sdk/client"; import { getDomain } from "tldts"; // --------------------------------------------------------------------------- @@ -19,17 +20,81 @@ export function sourceLocalIconUrl(sourceId: string | undefined): string | null return "/favicon-32.png"; } +const KIND_TO_PLUGIN_KEY: Record = { + openapi: "openapi", + mcp: "mcp", + graphql: "graphql", + googleDiscovery: "googleDiscovery", +}; + +const normalizeUrl = (url: string | undefined): string | null => { + if (!url) return null; + try { + const parsed = new URL(url); + parsed.hash = ""; + parsed.searchParams.sort(); + return parsed.toString().replace(/\/$/, ""); + } catch { + return url.trim().replace(/\/$/, ""); + } +}; + +const normalizeToken = (value: string | undefined): string => + value?.toLowerCase().replace(/[^a-z0-9]+/g, "") ?? ""; + +const tokenMatches = (sourceValue: string, presetValue: string): boolean => + presetValue.length > 0 && + sourceValue.length > 0 && + (sourceValue === presetValue || + sourceValue.includes(presetValue) || + presetValue.includes(sourceValue)); + +export function sourcePresetIconUrl( + source: { + readonly id: string; + readonly kind: string; + readonly name?: string; + readonly url?: string; + }, + sourcePlugins: readonly SourcePlugin[], +): string | null { + const pluginKey = KIND_TO_PLUGIN_KEY[source.kind] ?? source.kind; + const plugin = sourcePlugins.find((p) => p.key === pluginKey); + const presets = plugin?.presets ?? []; + const sourceUrl = normalizeUrl(source.url); + const sourceId = normalizeToken(source.id); + const sourceName = normalizeToken(source.name); + + const preset = presets.find((p) => { + const presetUrl = normalizeUrl(p.url); + const presetId = normalizeToken(p.id); + const presetName = normalizeToken(p.name); + return ( + (sourceUrl !== null && presetUrl === sourceUrl) || + tokenMatches(sourceId, presetId) || + tokenMatches(sourceName, presetName) + ); + }); + + return preset?.icon ?? null; +} + export function SourceFavicon({ + icon, sourceId, url, size = 16, }: { + icon?: string | null; sourceId?: string; url?: string; size?: number; }) { - const [failed, setFailed] = useState(false); - const src = failed ? null : (sourceLocalIconUrl(sourceId) ?? sourceFaviconUrl(url, size)); + const [failedSrcs, setFailedSrcs] = useState([]); + const src = + [icon ?? null, sourceLocalIconUrl(sourceId), sourceFaviconUrl(url, size)].find( + (candidate) => candidate !== null && !failedSrcs.includes(candidate), + ) ?? null; if (!src) { return ( @@ -48,7 +113,9 @@ export function SourceFavicon({ width={size} height={size} loading="lazy" - onError={() => setFailed(true)} + onError={() => + setFailedSrcs((current) => (current.includes(src) ? current : [...current, src])) + } className="shrink-0 rounded-sm" style={{ width: size, height: size }} /> diff --git a/packages/react/src/pages/sources.tsx b/packages/react/src/pages/sources.tsx index 7ec4a4120..963378f47 100644 --- a/packages/react/src/pages/sources.tsx +++ b/packages/react/src/pages/sources.tsx @@ -29,7 +29,7 @@ import { CardStackEntryMedia, CardStackEntryTitle, } from "../components/card-stack"; -import { SourceFavicon } from "../components/source-favicon"; +import { SourceFavicon, sourcePresetIconUrl } from "../components/source-favicon"; import { Skeleton } from "../components/skeleton"; const KIND_TO_PLUGIN_KEY: Record = { @@ -411,7 +411,12 @@ function SourceGrid(props: { - + {s.name}