import type { ProjectIconColor } from "@t3tools/contracts";import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";import { getProjectFaviconResourceKey, isProjectFaviconFallbackUrl,} from "@t3tools/shared/projectFavicon";import { BotIcon, BookOpenIcon, BracesIcon, CircuitBoardIcon, CloudCogIcon, Code2Icon, DatabaseIcon, FlaskConicalIcon, FolderCodeIcon, Gamepad2Icon, Globe2Icon, ImageIcon, Layers3Icon, MonitorIcon, MusicIcon, PackageIcon, ServerIcon, ShieldCheckIcon, ShoppingBagIcon, SmartphoneIcon, TerminalIcon, VideoIcon,} from "lucide-react";import type { IconName } from "lucide-react/dynamic";import type { ComponentType } from "react";import { lazy, Suspense, useState } from "react";import { useAtomValue } from "@effect/atom-react";import { projectFaviconUrlAtom } from "../state/assets";import { selectProjectIcon, type ProjectIconName } from "../projectIconModel";import { projectIconColorClassName } from "../projectIconColors";import { cn } from "~/lib/utils"; const DynamicIcon = lazy(() => import("lucide-react/dynamic").then((module) => ({ default: module.DynamicIcon })),); function DynamicProjectIconFallback() { return <FolderCodeIcon className="size-full text-[inherit]" />;} const PROJECT_ICONS: Record<ProjectIconName, ComponentType<{ className?: string }>> = { ai: BotIcon, book: BookOpenIcon, braces: BracesIcon, circuit: CircuitBoardIcon, cloud: CloudCogIcon, code: Code2Icon, database: DatabaseIcon, desktop: MonitorIcon, "folder-code": FolderCodeIcon, game: Gamepad2Icon, image: ImageIcon, layers: Layers3Icon, mobile: SmartphoneIcon, music: MusicIcon, package: PackageIcon, security: ShieldCheckIcon, server: ServerIcon, shopping: ShoppingBagIcon, terminal: TerminalIcon, test: FlaskConicalIcon, video: VideoIcon, web: Globe2Icon,}; const PROJECT_ICON_COLOR_BY_NAME: Record<ProjectIconName, ProjectIconColor> = { ai: "violet", book: "amber", braces: "purple", circuit: "teal", cloud: "sky", code: "blue", database: "cyan", desktop: "indigo", "folder-code": "orange", game: "emerald", image: "pink", layers: "fuchsia", mobile: "lime", music: "fuchsia", package: "orange", security: "teal", server: "blue", shopping: "rose", terminal: "green", test: "yellow", video: "red", web: "sky",}; // The slice of a project that decides its icon. Every surface must pass the// project record itself (or a snapshot spread from it) so the saved title, favicon// and icon override always travel together. Passing a display label as the title// changes the automatic icon, which is how the command palette drifted once.export type ProjectFaviconProject = Pick< EnvironmentProject, "environmentId" | "workspaceRoot" | "title" | "faviconPath" | "projectIcon">; export function ProjectFavicon(input: { project: ProjectFaviconProject; className?: string | undefined; fallbackIcon?: ComponentType<{ className?: string }>;}) { const { project } = input; const src = useAtomValue( projectFaviconUrlAtom({ environmentId: project.environmentId, cwd: project.workspaceRoot, faviconPath: project.faviconPath, }), ); if (project.projectIcon?.kind === "emoji") { return <ProjectFaviconFallback className={input.className} emoji={project.projectIcon.emoji} />; } if (project.projectIcon?.kind === "lucide") { const colorClassName = projectIconColorClassName(project.projectIcon.color); const iconClassName = cn( "inline-flex size-3.5 shrink-0 items-center justify-center", colorClassName, input.className, ); return ( <span aria-hidden="true" className={iconClassName}> <Suspense fallback={<DynamicProjectIconFallback />}> <DynamicIcon name={project.projectIcon.name as IconName} className={cn("size-full", colorClassName)} fallback={DynamicProjectIconFallback} /> </Suspense> </span> ); } const automaticIconName = input.fallbackIcon ? null : selectProjectIcon(project.title, project.workspaceRoot); const FallbackIcon = input.fallbackIcon ?? (automaticIconName?.kind === "lucide" ? PROJECT_ICONS[automaticIconName.icon] : undefined); const fallbackEmoji = automaticIconName?.kind === "emoji" ? automaticIconName.emoji : undefined; const fallbackColorClassName = automaticIconName?.kind === "lucide" ? projectIconColorClassName(PROJECT_ICON_COLOR_BY_NAME[automaticIconName.icon]) : undefined; if (!src || isProjectFaviconFallbackUrl(src)) { return ( <ProjectFaviconFallback className={input.className} colorClassName={fallbackColorClassName} icon={FallbackIcon} emoji={fallbackEmoji} /> ); } const cacheKey = getProjectFaviconResourceKey( project.environmentId, project.workspaceRoot, project.faviconPath, ); return ( <ProjectFaviconImage key={cacheKey} src={src} className={input.className} fallbackIcon={FallbackIcon} fallbackEmoji={fallbackEmoji} fallbackColorClassName={fallbackColorClassName} /> );} function ProjectFaviconFallback({ className, colorClassName, icon: Icon, emoji,}: { readonly className?: string | undefined; readonly colorClassName?: string | undefined; readonly icon?: ComponentType<{ className?: string }> | undefined; readonly emoji?: string | undefined;}) { if (emoji) { return ( <span aria-hidden="true" className={cn( "inline-flex size-3.5 shrink-0 items-center justify-center leading-none [container-type:size]", className, )} > <span className="text-[length:80cqh] leading-none">{emoji}</span> </span> ); } if (!Icon) return null; return <Icon className={cn("size-3.5 shrink-0 text-icon-muted", colorClassName, className)} />;} function ProjectFaviconImage({ src, className, fallbackIcon: FallbackIcon, fallbackEmoji, fallbackColorClassName,}: { readonly src: string; readonly className?: string | undefined; readonly fallbackIcon?: ComponentType<{ className?: string }> | undefined; readonly fallbackEmoji?: string | undefined; readonly fallbackColorClassName?: string | undefined;}) { const [displayedSrc, setDisplayedSrc] = useState<string | null>(() => src.startsWith("data:image/") ? src : null, ); const isLoading = displayedSrc !== src; const handleLoadError = (failedSrc: string) => { setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc)); }; return ( <> {displayedSrc === null ? ( <ProjectFaviconFallback className={className} colorClassName={fallbackColorClassName} icon={FallbackIcon} emoji={fallbackEmoji} /> ) : null} {displayedSrc ? ( <img src={displayedSrc} alt="" className={cn("size-3.5 shrink-0 rounded-[37.5%] object-contain", className)} onError={() => handleLoadError(displayedSrc)} /> ) : null} {isLoading ? ( <img src={src} alt="" className="hidden" onLoad={() => { setDisplayedSrc(src); }} onError={() => handleLoadError(src)} /> ) : null} </> );}
import type { ProjectIconColor } from "@t3tools/contracts";
import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
import {
getProjectFaviconResourceKey,
isProjectFaviconFallbackUrl,
} from "@t3tools/shared/projectFavicon";
import {
BotIcon,
BookOpenIcon,
BracesIcon,
CircuitBoardIcon,
CloudCogIcon,
Code2Icon,
DatabaseIcon,
FlaskConicalIcon,
FolderCodeIcon,
Gamepad2Icon,
Globe2Icon,
ImageIcon,
Layers3Icon,
MonitorIcon,
MusicIcon,
PackageIcon,
ServerIcon,
ShieldCheckIcon,
ShoppingBagIcon,
SmartphoneIcon,
TerminalIcon,
VideoIcon,
} from "lucide-react";
import type { IconName } from "lucide-react/dynamic";
import type { ComponentType } from "react";
import { lazy, Suspense, useState } from "react";
import { useAtomValue } from "@effect/atom-react";
import { projectFaviconUrlAtom } from "../state/assets";
import { selectProjectIcon, type ProjectIconName } from "../projectIconModel";
import { projectIconColorClassName } from "../projectIconColors";
import { cn } from "~/lib/utils";
const DynamicIcon = lazy(() =>
import("lucide-react/dynamic").then((module) => ({ default: module.DynamicIcon })),
);
function DynamicProjectIconFallback() {
return <FolderCodeIcon className="size-full text-[inherit]" />;
}
const PROJECT_ICONS: Record<ProjectIconName, ComponentType<{ className?: string }>> = {
ai: BotIcon,
book: BookOpenIcon,
braces: BracesIcon,
circuit: CircuitBoardIcon,
cloud: CloudCogIcon,
code: Code2Icon,
database: DatabaseIcon,
desktop: MonitorIcon,
"folder-code": FolderCodeIcon,
game: Gamepad2Icon,
image: ImageIcon,
layers: Layers3Icon,
mobile: SmartphoneIcon,
music: MusicIcon,
package: PackageIcon,
security: ShieldCheckIcon,
server: ServerIcon,
shopping: ShoppingBagIcon,
terminal: TerminalIcon,
test: FlaskConicalIcon,
video: VideoIcon,
web: Globe2Icon,
};
const PROJECT_ICON_COLOR_BY_NAME: Record<ProjectIconName, ProjectIconColor> = {
ai: "violet",
book: "amber",
braces: "purple",
circuit: "teal",
cloud: "sky",
code: "blue",
database: "cyan",
desktop: "indigo",
"folder-code": "orange",
game: "emerald",
image: "pink",
layers: "fuchsia",
mobile: "lime",
music: "fuchsia",
package: "orange",
security: "teal",
server: "blue",
shopping: "rose",
terminal: "green",
test: "yellow",
video: "red",
web: "sky",
};
// The slice of a project that decides its icon. Every surface must pass the
// project record itself (or a snapshot spread from it) so the saved title, favicon
// and icon override always travel together. Passing a display label as the title
// changes the automatic icon, which is how the command palette drifted once.
export type ProjectFaviconProject = Pick<
EnvironmentProject,
"environmentId" | "workspaceRoot" | "title" | "faviconPath" | "projectIcon"
>;
export function ProjectFavicon(input: {
project: ProjectFaviconProject;
className?: string | undefined;
fallbackIcon?: ComponentType<{ className?: string }>;
}) {
const { project } = input;
const src = useAtomValue(
projectFaviconUrlAtom({
environmentId: project.environmentId,
cwd: project.workspaceRoot,
faviconPath: project.faviconPath,
}),
);
if (project.projectIcon?.kind === "emoji") {
return <ProjectFaviconFallback className={input.className} emoji={project.projectIcon.emoji} />;
}
if (project.projectIcon?.kind === "lucide") {
const colorClassName = projectIconColorClassName(project.projectIcon.color);
const iconClassName = cn(
"inline-flex size-3.5 shrink-0 items-center justify-center",
colorClassName,
input.className,
);
return (
<span aria-hidden="true" className={iconClassName}>
<Suspense fallback={<DynamicProjectIconFallback />}>
<DynamicIcon
name={project.projectIcon.name as IconName}
className={cn("size-full", colorClassName)}
fallback={DynamicProjectIconFallback}
/>
</Suspense>
</span>
);
}
const automaticIconName = input.fallbackIcon
? null
: selectProjectIcon(project.title, project.workspaceRoot);
const FallbackIcon =
input.fallbackIcon ??
(automaticIconName?.kind === "lucide" ? PROJECT_ICONS[automaticIconName.icon] : undefined);
const fallbackEmoji = automaticIconName?.kind === "emoji" ? automaticIconName.emoji : undefined;
const fallbackColorClassName =
automaticIconName?.kind === "lucide"
? projectIconColorClassName(PROJECT_ICON_COLOR_BY_NAME[automaticIconName.icon])
: undefined;
if (!src || isProjectFaviconFallbackUrl(src)) {
return (
<ProjectFaviconFallback
className={input.className}
colorClassName={fallbackColorClassName}
icon={FallbackIcon}
emoji={fallbackEmoji}
/>
);
}
const cacheKey = getProjectFaviconResourceKey(
project.environmentId,
project.workspaceRoot,
project.faviconPath,
);
return (
<ProjectFaviconImage
key={cacheKey}
src={src}
className={input.className}
fallbackIcon={FallbackIcon}
fallbackEmoji={fallbackEmoji}
fallbackColorClassName={fallbackColorClassName}
/>
);
}
function ProjectFaviconFallback({
className,
colorClassName,
icon: Icon,
emoji,
}: {
readonly className?: string | undefined;
readonly colorClassName?: string | undefined;
readonly icon?: ComponentType<{ className?: string }> | undefined;
readonly emoji?: string | undefined;
}) {
if (emoji) {
return (
<span
aria-hidden="true"
className={cn(
"inline-flex size-3.5 shrink-0 items-center justify-center leading-none [container-type:size]",
className,
)}
>
<span className="text-[length:80cqh] leading-none">{emoji}</span>
</span>
);
}
if (!Icon) return null;
return <Icon className={cn("size-3.5 shrink-0 text-icon-muted", colorClassName, className)} />;
}
function ProjectFaviconImage({
src,
className,
fallbackIcon: FallbackIcon,
fallbackEmoji,
fallbackColorClassName,
}: {
readonly src: string;
readonly className?: string | undefined;
readonly fallbackIcon?: ComponentType<{ className?: string }> | undefined;
readonly fallbackEmoji?: string | undefined;
readonly fallbackColorClassName?: string | undefined;
}) {
const [displayedSrc, setDisplayedSrc] = useState<string | null>(() =>
src.startsWith("data:image/") ? src : null,
);
const isLoading = displayedSrc !== src;
const handleLoadError = (failedSrc: string) => {
setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc));
};
return (
<>
{displayedSrc === null ? (
<ProjectFaviconFallback
className={className}
colorClassName={fallbackColorClassName}
icon={FallbackIcon}
emoji={fallbackEmoji}
/>
) : null}
{displayedSrc ? (
<img
src={displayedSrc}
alt=""
className={cn("size-3.5 shrink-0 rounded-[37.5%] object-contain", className)}
onError={() => handleLoadError(displayedSrc)}
/>
) : null}
{isLoading ? (
<img
src={src}
alt=""
className="hidden"
onLoad={() => {
setDisplayedSrc(src);
}}
onError={() => handleLoadError(src)}
/>
) : null}
</>
);
}