Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions ui/src/components/ExtensionSelectionActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Puzzle } from "lucide-react";
import { extensions } from "../api/client";
import type { ExtensionAction } from "../api/types";
import { useExtensions } from "../extensions/ExtensionLoader";
import { useExtensions, renderExtensionIcon, resolveComponent } from "../extensions/ExtensionLoader";
import { registerManualContext } from "./ManualContext";

interface Props {
Expand Down Expand Up @@ -166,7 +166,12 @@ export function ExtensionSelectionActions({ entityType, selectedIds }: Props) {
disabled={invokeActionMut.isPending}
className="flex items-center gap-1 px-2 py-0.5 rounded text-xs text-accent hover:text-accent-hover hover:bg-accent/10 disabled:opacity-60"
>
{isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Puzzle className="w-3 h-3" />}
{isPending
? <Loader2 className="w-3 h-3 animate-spin" />
: renderExtensionIcon(action.icon, action.extensionId, resolveComponent, {
sizeClass: "h-3 w-3",
fallback: <Puzzle className="h-3 w-3" />,
})}
{action.label}
</button>
);
Expand Down
3 changes: 2 additions & 1 deletion ui/src/components/useExtensionTabs.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useMemo, useState, useEffect, type ReactNode } from "react";
import { useExtensions } from "../extensions/ExtensionLoader";
import { useExtensions, renderExtensionIcon } from "../extensions/ExtensionLoader";
import { ExtensionErrorBoundary } from "./ExtensionErrorBoundary";

interface Tab {
Expand Down Expand Up @@ -79,6 +79,7 @@ export function useExtensionTabs(pageType: string, builtInTabs: Tab[], entityId?
const ext = extTabs.map((t) => ({
key: `ext:${t.key}`,
label: t.label,
icon: renderExtensionIcon(t.icon, t.extensionId, resolveComponent),
count: extCounts[t.key],
order: t.order,
manualContexts: t.manualContexts,
Expand Down
65 changes: 65 additions & 0 deletions ui/src/extensions/ExtensionLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
* or would load from JS bundles (for external extensions)
*/
import { useEffect, useState, createContext, useContext, useCallback, useMemo, type ReactNode, type FC } from "react";
import { ExtensionErrorBoundary } from "../components/ExtensionErrorBoundary";
import { useRouteRegistry } from "../router/RouteRegistry";
import { useAppConfig } from "../state/AppConfigContext";
import { extensions } from "../api/client";
Expand Down Expand Up @@ -42,6 +43,70 @@ function resolveIcon(name?: string): LucideIcon | undefined {
return name ? ICON_MAP[name.toLowerCase()] : undefined;
}

// A string is an image source (rendered as <img>) when it is an http(s) URL, a data: URI, or a
// root-relative path — never a bare component/built-in name.
function isImageSource(icon: string): boolean {
return /^(https?:\/\/|data:image\/|\/)/.test(icon);
}

/** How an extension icon is sized/fallen-back on a given surface. */
export interface ExtensionIconOptions {
/** Tailwind size classes (default `h-4 w-4`); the tab rail, action bar, and list use different sizes. */
sizeClass?: string;
/** Rendered when the icon does not resolve; omit to return `undefined` (the caller draws its default). */
fallback?: ReactNode;
}

// Resolves ANY extension-contributed icon to a renderable node through ONE precedence — so every
// surface (detail-rail tab, settings nav, bulk-action bar, installed-extensions list) resolves icons
// the same way rather than one expecting a component and another a URL. Order: a host built-in named
// icon, then a component the extension registered under that name (its own brand logo), then — for a
// roster entry that ships only an asset — an <img> when the value is a URL / data-URI / path.
// Built-in-first so a shared icon name is never shadowed by an extension's content component. The
// registered component is sandboxed in an error boundary and clamped to icon size so a crashing or
// oversized icon can neither break the surface nor blow out its layout. An unresolved value returns
// the caller's `fallback` (default `undefined`) and warns in dev, so a typo surfaces.
export function renderExtensionIcon(
icon: string | undefined,
extensionId: string,
resolveComponent: (name: string) => FC<any> | undefined,
options?: ExtensionIconOptions,
): ReactNode | undefined {
const size = options?.sizeClass ?? "h-4 w-4";
if (!icon) return options?.fallback;

const BuiltInIcon = resolveIcon(icon);
if (BuiltInIcon) {
return (
<span className={`inline-flex items-center justify-center ${size}`}>
<BuiltInIcon className="h-full w-full" />
</span>
);
}

const IconComponent = resolveComponent(icon);
if (IconComponent) {
return (
<ExtensionErrorBoundary extensionId={extensionId} fallback={<Puzzle className="h-full w-full" />}>
<span className={`inline-flex items-center justify-center overflow-hidden ${size}`}>
<IconComponent className="h-full w-full" />
</span>
</ExtensionErrorBoundary>
);
}

if (isImageSource(icon)) {
return <img src={icon} alt="" className={`rounded object-cover ${size}`} />;
}

if (import.meta.env.DEV) {
console.warn(
`[extensions] ${extensionId}: icon "${icon}" did not resolve to a built-in icon, a registered component, or an image source; using the default glyph.`,
);
}
return options?.fallback;
}

// ============================================================================
// Built-in component registry — populated by external extensions at runtime.
// External extensions deliver their components via JS bundles and call
Expand Down
32 changes: 27 additions & 5 deletions ui/src/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type FC, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as signalR from "@microsoft/signalr";
import { formatDate } from "../components/shared";
Expand Down Expand Up @@ -68,7 +68,11 @@ import type {
TagGroup,
UserTrackingPreferences,
} from "../api/types";
import { useExtensions } from "../extensions/ExtensionLoader";
import {
useExtensions,
resolveComponent as resolveExtensionComponent,
renderExtensionIcon,
} from "../extensions/ExtensionLoader";
import { getScraperSiteKey } from "../components/videoScrapeUtils";
import { useAppConfig } from "../state/AppConfigContext";
import { LOCATION_CHANGE_EVENT, buildCurrentUrl, navigateToUrl } from "../router/location";
Expand Down Expand Up @@ -136,10 +140,15 @@ type BuiltInSettingsTab =
| "system-info-runtime-status"
| "logs";
type SettingsTab = BuiltInSettingsTab | string;
// A settings-nav tab icon is either a host built-in (Lucide) component or an extension-registered
// SVG component; both accept `className`, so both render identically at the `<Icon className=… />`
// call sites.
type SettingsTabIcon = typeof FolderOpen | FC<{ className?: string }>;

type SettingsTabDefinition = {
key: SettingsTab;
label: string;
icon: typeof FolderOpen;
icon: SettingsTabIcon;
order?: number;
parentTabKey?: SettingsTab;
description?: string;
Expand Down Expand Up @@ -434,13 +443,21 @@ const extensionSettingsTabIcons: Record<string, typeof FolderOpen> = {
users: Users,
};

function resolveExtensionSettingsTabIcon(iconName?: string): typeof FolderOpen {
function resolveExtensionSettingsTabIcon(iconName?: string): SettingsTabIcon {
if (!iconName) {
return Plug;
}

const normalized = iconName.replace(/[^a-z0-9]/gi, "").toLowerCase();
return extensionSettingsTabIcons[normalized] ?? Plug;
const builtIn = extensionSettingsTabIcons[normalized];
if (builtIn) {
return builtIn;
}

// Fall back to a component the extension registered under this name (e.g. a brand logo). Use the
// original, case-sensitive name — registered component names are case-sensitive ("WhisparrLogo").
const registered = resolveExtensionComponent(iconName);
return registered ?? Plug;
}

const SETTINGS_TAB_QUERY_KEY = "tab";
Expand Down Expand Up @@ -6169,6 +6186,7 @@ function ExtensionsPanel({ mode }: { mode: "installed" | "registry" }) {
description?: string;
author?: string;
url?: string;
iconUrl?: string | null;
enabled: boolean;
kind: string;
categories: string[];
Expand Down Expand Up @@ -6197,6 +6215,7 @@ function ExtensionsPanel({ mode }: { mode: "installed" | "registry" }) {
description: ext.description,
author: ext.author,
url: ext.url,
iconUrl: ext.iconUrl,
enabled: ext.enabled,
kind: ext.kind ?? "extension",
categories: ext.categories,
Expand Down Expand Up @@ -6419,6 +6438,9 @@ function ExtensionsPanel({ mode }: { mode: "installed" | "registry" }) {
<div className={`w-2 h-2 rounded-full shrink-0 ${ext.enabled ? "bg-green-400" : "bg-gray-500"}`} />
<div className="min-w-0">
<div className="font-medium text-sm flex items-center gap-2 flex-wrap">
{renderExtensionIcon(ext.iconUrl ?? undefined, ext.id, resolveExtensionComponent, {
sizeClass: "h-5 w-5",
})}
{ext.name}
<span className="text-xs text-muted">v{ext.version}</span>
{update && (
Expand Down
9 changes: 7 additions & 2 deletions ui/src/pages/VideoDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
} from "../components/segmentFilter";
import { useVideoQueue, type VideoQueueItem } from "../state/VideoQueueContext";
import { useAppConfig } from "../state/AppConfigContext";
import { useExtensions } from "../extensions/ExtensionLoader";
import { useExtensions, renderExtensionIcon } from "../extensions/ExtensionLoader";
import { createRouteLinkProps } from "../components/cardNavigation";
import { StringListEditor } from "../components/StringListEditor";
import { StudioSelector } from "../components/StudioSelector";
Expand Down Expand Up @@ -461,7 +461,12 @@ export function VideoDetailPage({ id, initialSeekTo, onNavigate }: Props) {
{ key: "filters", label: "Filters" },
{ key: "file-info", label: `File Info${video?.files.length && video.files.length > 1 ? ` (${video.files.length})` : ""}` },
{ key: "history", label: "History" },
...videoExtTabs.map((t) => ({ key: `ext:${t.key}` as TabKey, label: t.label, manualContexts: t.manualContexts })),
...videoExtTabs.map((t) => ({
key: `ext:${t.key}` as TabKey,
label: t.label,
icon: renderExtensionIcon(t.icon, t.extensionId, resolveExtComponent),
manualContexts: t.manualContexts,
})),
{ key: "edit", label: "Edit" },
], {
segments: "segments.read",
Expand Down
71 changes: 71 additions & 0 deletions ui/src/test/extensionTabIcon.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderExtensionIcon } from "../extensions/ExtensionLoader";

afterEach(cleanup);

// A stand-in for an extension's own registered component (e.g. its brand logo).
const FakeLogo = ({ className }: { className?: string }) => (
<svg data-testid="fake-logo" className={className} />
);
const resolveComponent = (name: string) => (name === "WhisparrLogo" ? FakeLogo : undefined);

describe("renderExtensionIcon", () => {
it("renders the extension's own registered component for a bare name (brand logo)", () => {
const node = renderExtensionIcon("WhisparrLogo", "com.example.ext", resolveComponent);
const { container } = render(<>{node}</>);
expect(container.querySelector('[data-testid="fake-logo"]')).not.toBeNull();
});

it("renders a host built-in named icon by name (no registration needed)", () => {
const node = renderExtensionIcon("puzzle", "com.example.ext", resolveComponent);
const { container } = render(<>{node}</>);
// resolveIcon("puzzle") → the built-in Lucide component, rendered as an <svg>.
expect(container.querySelector("svg")).not.toBeNull();
// It is the built-in, not the extension's fake logo.
expect(container.querySelector('[data-testid="fake-logo"]')).toBeNull();
});

it("built-in name wins over a same-named registered component (no shadowing)", () => {
// An extension that registers a component literally named "puzzle" cannot hijack the built-in.
const resolvePuzzleComponent = (name: string) => (name === "puzzle" ? FakeLogo : undefined);
const { container } = render(
<>{renderExtensionIcon("puzzle", "com.example.ext", resolvePuzzleComponent)}</>,
);
expect(container.querySelector('[data-testid="fake-logo"]')).toBeNull();
});

it("renders an <img> when the value is an image source (URL / data-URI / path)", () => {
// The installed-extensions roster ships an asset URL rather than a registered component, and it
// resolves through the SAME function — the precedence is unified across every surface.
for (const src of ["https://example.com/logo.png", "data:image/png;base64,AAAA", "/icons/x.svg"]) {
const { container } = render(
<>{renderExtensionIcon(src, "com.example.ext", resolveComponent)}</>,
);
const img = container.querySelector("img");
expect(img).not.toBeNull();
expect(img?.getAttribute("src")).toBe(src);
cleanup();
}
});

it("returns undefined for an unknown name so the host default applies, and warns in dev", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(renderExtensionIcon("DoesNotExist", "com.example.ext", resolveComponent)).toBeUndefined();
expect(warn).toHaveBeenCalledOnce();
warn.mockRestore();
});

it("returns the caller-supplied fallback when the icon does not resolve", () => {
const node = renderExtensionIcon("DoesNotExist", "com.example.ext", resolveComponent, {
fallback: <span data-testid="fallback" />,
});
const { container } = render(<>{node}</>);
expect(container.querySelector('[data-testid="fallback"]')).not.toBeNull();
});

it("returns the fallback (or undefined) for an empty or missing icon value", () => {
expect(renderExtensionIcon("", "com.example.ext", resolveComponent)).toBeUndefined();
expect(renderExtensionIcon(undefined, "com.example.ext", resolveComponent)).toBeUndefined();
});
});
Loading