1import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
2import {
3 getProjectFaviconResourceKey,
4 isProjectFaviconFallbackUrl,
5} from "@t3tools/shared/projectFavicon";
6import { FolderCodeIcon } from "lucide-react";
7import type { IconName } from "lucide-react/dynamic";
8import type { ComponentType } from "react";
9import { lazy, Suspense, useState } from "react";
10import { useAtomValue } from "@effect/atom-react";
11import { projectFaviconUrlAtom } from "../state/assets";
12import { deriveProjectIdentity } from "../projectIdentity";
13import { projectIconColorClassName } from "../projectIconColors";
14import { ProjectMonogram } from "./ProjectMonogram";
15import { cn } from "~/lib/utils";
16
17const DynamicIcon = lazy(() =>
18 import("lucide-react/dynamic").then((module) => ({ default: module.DynamicIcon })),
19);
20
21function DynamicProjectIconFallback() {
22 return <FolderCodeIcon className="size-full text-inherit" />;
23}
24
25// The slice of a project that decides its icon. Every surface must pass the
26// project record itself (or a snapshot spread from it) so the saved title, favicon
27// and icon override always travel together. Passing a display label as the title
28// changes the automatic icon, which is how the command palette drifted once.
29export type ProjectFaviconProject = Pick<
30 EnvironmentProject,
31 "environmentId" | "workspaceRoot" | "title" | "faviconPath" | "projectIcon"
32>;
33export function ProjectFavicon(input: {
34 project: ProjectFaviconProject;
35 className?: string | undefined;
36 fallbackIcon?: ComponentType<{ className?: string }>;
37}) {
38 const { project } = input;
39 const src = useAtomValue(
40 projectFaviconUrlAtom({
41 environmentId: project.environmentId,
42 cwd: project.workspaceRoot,
43 faviconPath: project.faviconPath,
44 }),
45 );
46 if (project.projectIcon?.kind === "monogram") {
47 return (
48 <ProjectMonogram
49 text={project.projectIcon.text}
50 color={project.projectIcon.color}
51 className={input.className}
52 />
53 );
54 }
55 if (project.projectIcon?.kind === "emoji") {
56 return (
57 <ProjectFaviconFallback
58 className={input.className}
59 icon={FolderCodeIcon}
60 emoji={project.projectIcon.emoji}
61 />
62 );
63 }
64 if (project.projectIcon?.kind === "lucide") {
65 const colorClassName = projectIconColorClassName(project.projectIcon.color);
66 const iconClassName = cn(
67 "inline-flex size-3.5 shrink-0 items-center justify-center",
68 colorClassName,
69 input.className,
70 );
71 return (
72 <span aria-hidden="true" className={iconClassName}>
73 <Suspense fallback={<DynamicProjectIconFallback />}>
74 <DynamicIcon
75 name={project.projectIcon.name as IconName}
76 className={cn("size-full", colorClassName)}
77 fallback={DynamicProjectIconFallback}
78 />
79 </Suspense>
80 </span>
81 );
82 }
83 const FallbackIcon = input.fallbackIcon ?? FolderCodeIcon;
84
85 if (!src || isProjectFaviconFallbackUrl(src)) {
86 return (
87 <ProjectFaviconFallback
88 className={input.className}
89 icon={FallbackIcon}
90 projectName={project.title}
91 />
92 );
93 }
94
95 const cacheKey = getProjectFaviconResourceKey(
96 project.environmentId,
97 project.workspaceRoot,
98 project.faviconPath,
99 );
100
101 return (
102 <ProjectFaviconImage
103 key={cacheKey}
104 src={src}
105 className={input.className}
106 fallbackIcon={FallbackIcon}
107 fallbackProjectName={project.title}
108 />
109 );
110}
111
112function ProjectFaviconFallback({
113 className,
114 icon: Icon,
115 emoji,
116 projectName,
117}: {
118 readonly className?: string | undefined;
119 readonly icon: ComponentType<{ className?: string }>;
120 readonly emoji?: string | undefined;
121 readonly projectName?: string | undefined;
122}) {
123 if (projectName && projectName.trim().length > 0) {
124 const identity = deriveProjectIdentity(projectName);
125 return (
126 <ProjectMonogram text={identity.monogram} color={identity.color} className={className} />
127 );
128 }
129
130 if (emoji) {
131 return (
132 <span
133 aria-hidden="true"
134 className={cn(
135 "inline-flex size-3.5 shrink-0 items-center justify-center leading-none [container-type:size]",
136 className,
137 )}
138 >
139 <span className="text-[length:80cqh] leading-none">{emoji}</span>
140 </span>
141 );
142 }
143
144 return <Icon className={cn("size-3.5 shrink-0 text-icon-muted", className)} />;
145}
146
147function ProjectFaviconImage({
148 src,
149 className,
150 fallbackIcon: FallbackIcon,
151 fallbackProjectName,
152}: {
153 readonly src: string;
154 readonly className?: string | undefined;
155 readonly fallbackIcon: ComponentType<{ className?: string }>;
156 readonly fallbackProjectName?: string | undefined;
157}) {
158 const [displayedSrc, setDisplayedSrc] = useState<string | null>(() =>
159 src.startsWith("data:image/") ? src : null,
160 );
161 const isLoading = displayedSrc !== src;
162 const handleLoadError = (failedSrc: string) => {
163 setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc));
164 };
165
166 return (
167 <>
168 {displayedSrc === null ? (
169 <ProjectFaviconFallback
170 className={className}
171 icon={FallbackIcon}
172 projectName={fallbackProjectName}
173 />
174 ) : null}
175 {displayedSrc ? (
176 <img
177 src={displayedSrc}
178 alt=""
179 className={cn("size-3.5 shrink-0 rounded-[25%] object-contain", className)}
180 onError={() => handleLoadError(displayedSrc)}
181 />
182 ) : null}
183 {isLoading ? (
184 <img
185 src={src}
186 alt=""
187 className="hidden"
188 onLoad={() => {
189 setDisplayedSrc(src);
190 }}
191 onError={() => handleLoadError(src)}
192 />
193 ) : null}
194 </>
195 );
196}