diff --git a/apps/server/src/projectFaviconRoute.test.ts b/apps/server/src/projectFaviconRoute.test.ts
index 34a430d..ac61dee 100644
--- a/apps/server/src/projectFaviconRoute.test.ts
+++ b/apps/server/src/projectFaviconRoute.test.ts
@@ -153,15 +153,53 @@ describe("tryHandleProjectFaviconRequest", () => {
});
});
- it("serves a fallback favicon when no icon exists", async () => {
- const projectDir = makeTempDir("t3code-favicon-route-fallback-");
+ it("resolves a favicon from a web app inside apps/", async () => {
+ const projectDir = makeTempDir("t3code-favicon-route-apps-root-");
+ const appDir = path.join(projectDir, "apps", "web");
+ const iconPath = path.join(appDir, "public", "favicon.ico");
+ fs.mkdirSync(path.dirname(iconPath), { recursive: true });
+ fs.writeFileSync(iconPath, "web-app-favicon", "utf8");
+
+ await withRouteServer(async (baseUrl) => {
+ const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
+ const response = await request(baseUrl, pathname);
+ expect(response.statusCode).toBe(200);
+ expect(response.contentType).toContain("image/x-icon");
+ expect(response.body).toBe("web-app-favicon");
+ });
+ });
+
+ it("searches apps/ recursively for nested web apps", async () => {
+ const projectDir = makeTempDir("t3code-favicon-route-apps-recursive-");
+ const appDir = path.join(projectDir, "apps", "client", "site");
+ const iconPath = path.join(appDir, "public", "brand", "logo.svg");
+ fs.mkdirSync(path.dirname(iconPath), { recursive: true });
+ fs.mkdirSync(path.join(appDir, "src"), { recursive: true });
+ fs.writeFileSync(
+ path.join(appDir, "index.html"),
+ '',
+ "utf8",
+ );
+ fs.writeFileSync(iconPath, "", "utf8");
await withRouteServer(async (baseUrl) => {
const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
const response = await request(baseUrl, pathname);
expect(response.statusCode).toBe(200);
expect(response.contentType).toContain("image/svg+xml");
- expect(response.body).toContain('data-fallback="project-favicon"');
+ expect(response.body).toBe("");
+ });
+ });
+
+ it("returns 204 when no favicon exists", async () => {
+ const projectDir = makeTempDir("t3code-favicon-route-fallback-");
+
+ await withRouteServer(async (baseUrl) => {
+ const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
+ const response = await request(baseUrl, pathname);
+ expect(response.statusCode).toBe(204);
+ expect(response.contentType).toBeNull();
+ expect(response.body).toBe("");
});
});
});
diff --git a/apps/server/src/projectFaviconRoute.ts b/apps/server/src/projectFaviconRoute.ts
index cf234ad..c20ff75 100644
--- a/apps/server/src/projectFaviconRoute.ts
+++ b/apps/server/src/projectFaviconRoute.ts
@@ -1,4 +1,5 @@
import fs from "node:fs";
+import fsPromises from "node:fs/promises";
import http from "node:http";
import path from "node:path";
@@ -9,16 +10,18 @@ const FAVICON_MIME_TYPES: Record = {
".ico": "image/x-icon",
};
-const FALLBACK_FAVICON_SVG = ``;
-
// Well-known favicon paths checked in order.
const FAVICON_CANDIDATES = [
"favicon.svg",
"favicon.ico",
"favicon.png",
+ "favicon-32x32.png",
+ "favicon-16x16.png",
"public/favicon.svg",
"public/favicon.ico",
"public/favicon.png",
+ "public/favicon-32x32.png",
+ "public/favicon-16x16.png",
"app/favicon.ico",
"app/favicon.png",
"app/icon.svg",
@@ -52,6 +55,28 @@ const LINK_ICON_HTML_RE =
const LINK_ICON_OBJ_RE =
/(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i;
+const IGNORED_APP_SEARCH_DIRECTORIES = new Set([
+ ".git",
+ ".next",
+ ".turbo",
+ "build",
+ "dist",
+ "node_modules",
+ "out",
+]);
+const APP_ROOT_MARKER_DIRECTORIES = new Set(["app", "public", "src"]);
+const APP_ROOT_MARKER_FILES = new Set(["index.html", "package.json"]);
+const APP_ROOT_MARKER_FILE_PREFIXES = [
+ "angular.",
+ "astro.config.",
+ "next.config.",
+ "nuxt.config.",
+ "remix.config.",
+ "svelte.config.",
+ "vite.config.",
+];
+const APPS_SEARCH_MAX_DEPTH = 4;
+
function extractIconHref(source: string): string | null {
const htmlMatch = source.match(LINK_ICON_HTML_RE);
if (htmlMatch?.[1]) return htmlMatch[1];
@@ -70,6 +95,134 @@ function isPathWithinProject(projectCwd: string, candidatePath: string): boolean
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
+async function isFile(filePath: string): Promise {
+ try {
+ const stats = await fsPromises.stat(filePath);
+ return stats.isFile();
+ } catch {
+ return false;
+ }
+}
+
+async function resolveFaviconFromProjectRoot(projectCwd: string): Promise {
+ for (const relativeCandidate of FAVICON_CANDIDATES) {
+ const candidatePath = path.join(projectCwd, relativeCandidate);
+ if (!isPathWithinProject(projectCwd, candidatePath)) {
+ continue;
+ }
+ if (await isFile(candidatePath)) {
+ return candidatePath;
+ }
+ }
+
+ for (const relativeSourceFile of ICON_SOURCE_FILES) {
+ const sourceFilePath = path.join(projectCwd, relativeSourceFile);
+ let content: string;
+ try {
+ content = await fsPromises.readFile(sourceFilePath, "utf8");
+ } catch {
+ continue;
+ }
+
+ const href = extractIconHref(content);
+ if (!href) {
+ continue;
+ }
+
+ for (const resolvedPath of resolveIconHref(projectCwd, href)) {
+ if (!isPathWithinProject(projectCwd, resolvedPath)) {
+ continue;
+ }
+ if (await isFile(resolvedPath)) {
+ return resolvedPath;
+ }
+ }
+ }
+
+ return null;
+}
+
+function looksLikeWebAppRoot(entries: readonly fs.Dirent[]): boolean {
+ for (const entry of entries) {
+ if (entry.isDirectory() && APP_ROOT_MARKER_DIRECTORIES.has(entry.name)) {
+ return true;
+ }
+ if (!entry.isFile()) {
+ continue;
+ }
+ if (APP_ROOT_MARKER_FILES.has(entry.name)) {
+ return true;
+ }
+ if (APP_ROOT_MARKER_FILE_PREFIXES.some((prefix) => entry.name.startsWith(prefix))) {
+ return true;
+ }
+ }
+ return false;
+}
+
+async function collectNestedAppRoots(
+ directoryPath: string,
+ depth: number,
+ roots: string[],
+): Promise {
+ let entries: fs.Dirent[];
+ try {
+ entries = await fsPromises.readdir(directoryPath, { withFileTypes: true });
+ } catch {
+ return;
+ }
+
+ const sortedEntries = entries.toSorted((left, right) => left.name.localeCompare(right.name));
+ if (depth > 0 && looksLikeWebAppRoot(sortedEntries)) {
+ roots.push(directoryPath);
+ return;
+ }
+
+ if (depth >= APPS_SEARCH_MAX_DEPTH) {
+ return;
+ }
+
+ for (const entry of sortedEntries) {
+ if (
+ !entry.isDirectory() ||
+ IGNORED_APP_SEARCH_DIRECTORIES.has(entry.name) ||
+ entry.name.startsWith(".")
+ ) {
+ continue;
+ }
+ await collectNestedAppRoots(path.join(directoryPath, entry.name), depth + 1, roots);
+ }
+}
+
+async function resolveProjectFaviconPath(projectCwd: string): Promise {
+ const faviconFromRoot = await resolveFaviconFromProjectRoot(projectCwd);
+ if (faviconFromRoot) {
+ return faviconFromRoot;
+ }
+
+ const appsDirectoryPath = path.join(projectCwd, "apps");
+ let appsDirectoryStats: fs.Stats;
+ try {
+ appsDirectoryStats = await fsPromises.stat(appsDirectoryPath);
+ } catch {
+ return null;
+ }
+ if (!appsDirectoryStats.isDirectory()) {
+ return null;
+ }
+
+ const nestedAppRoots: string[] = [];
+ await collectNestedAppRoots(appsDirectoryPath, 0, nestedAppRoots);
+ for (const appRoot of nestedAppRoots) {
+ const faviconFromApp = await resolveFaviconFromProjectRoot(appRoot);
+ if (faviconFromApp) {
+ return faviconFromApp;
+ }
+ }
+
+ return null;
+}
+
function serveFaviconFile(filePath: string, res: http.ServerResponse): void {
const ext = path.extname(filePath).toLowerCase();
const contentType = FAVICON_MIME_TYPES[ext] ?? "application/octet-stream";
@@ -87,12 +240,9 @@ function serveFaviconFile(filePath: string, res: http.ServerResponse): void {
});
}
-function serveFallbackFavicon(res: http.ServerResponse): void {
- res.writeHead(200, {
- "Content-Type": "image/svg+xml",
- "Cache-Control": "public, max-age=3600",
- });
- res.end(FALLBACK_FAVICON_SVG);
+function serveMissingFavicon(res: http.ServerResponse): void {
+ res.writeHead(204, { "Cache-Control": "no-store" });
+ res.end();
}
export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerResponse): boolean {
@@ -107,65 +257,17 @@ export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerRespons
return true;
}
- const tryResolvedPaths = (paths: string[], index: number, onExhausted: () => void): void => {
- if (index >= paths.length) {
- onExhausted();
- return;
- }
- const candidate = paths[index]!;
- if (!isPathWithinProject(projectCwd, candidate)) {
- tryResolvedPaths(paths, index + 1, onExhausted);
- return;
- }
- fs.stat(candidate, (err, stats) => {
- if (err || !stats?.isFile()) {
- tryResolvedPaths(paths, index + 1, onExhausted);
+ void resolveProjectFaviconPath(projectCwd)
+ .then((faviconPath) => {
+ if (!faviconPath) {
+ serveMissingFavicon(res);
return;
}
- serveFaviconFile(candidate, res);
- });
- };
-
- const trySourceFiles = (index: number): void => {
- if (index >= ICON_SOURCE_FILES.length) {
- serveFallbackFavicon(res);
- return;
- }
- const sourceFile = path.join(projectCwd, ICON_SOURCE_FILES[index]!);
- fs.readFile(sourceFile, "utf8", (err, content) => {
- if (err) {
- trySourceFiles(index + 1);
- return;
- }
- const href = extractIconHref(content);
- if (!href) {
- trySourceFiles(index + 1);
- return;
- }
- const candidates = resolveIconHref(projectCwd, href);
- tryResolvedPaths(candidates, 0, () => trySourceFiles(index + 1));
- });
- };
-
- const tryCandidates = (index: number): void => {
- if (index >= FAVICON_CANDIDATES.length) {
- trySourceFiles(0);
- return;
- }
- const candidate = path.join(projectCwd, FAVICON_CANDIDATES[index]!);
- if (!isPathWithinProject(projectCwd, candidate)) {
- tryCandidates(index + 1);
- return;
- }
- fs.stat(candidate, (err, stats) => {
- if (err || !stats?.isFile()) {
- tryCandidates(index + 1);
- return;
- }
- serveFaviconFile(candidate, res);
+ serveFaviconFile(faviconPath, res);
+ })
+ .catch(() => {
+ res.writeHead(500, { "Content-Type": "text/plain" });
+ res.end("Failed to resolve favicon");
});
- };
-
- tryCandidates(0);
return true;
}
diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx
index 83389c0..6693437 100644
--- a/apps/web/src/components/ProjectFavicon.tsx
+++ b/apps/web/src/components/ProjectFavicon.tsx
@@ -1,5 +1,5 @@
-import { FolderIcon } from "lucide-react";
-import { useState } from "react";
+import { FolderIcon, type LucideIcon } from "lucide-react";
+import { useEffect, useState } from "react";
import { cn } from "../lib/utils";
function getServerHttpOrigin(): string {
@@ -25,13 +25,19 @@ export function ProjectFavicon(props: {
cwd: string;
className?: string;
fallbackClassName?: string;
+ fallbackIcon?: LucideIcon;
}) {
const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading");
const src = `${serverHttpOrigin}/api/project-favicon?cwd=${encodeURIComponent(props.cwd)}`;
+ const FallbackIcon = props.fallbackIcon ?? FolderIcon;
+
+ useEffect(() => {
+ setStatus("loading");
+ }, [src]);
if (status === "error") {
return (
-
);
@@ -46,7 +52,10 @@ export function ProjectFavicon(props: {
status === "loading" && "hidden",
props.className,
)}
- onLoad={() => setStatus("loaded")}
+ onLoad={(event) => {
+ const image = event.currentTarget;
+ setStatus(image.naturalWidth > 0 || image.naturalHeight > 0 ? "loaded" : "error");
+ }}
onError={() => setStatus("error")}
/>
);
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index e0792fa..6ebc5e8 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -82,6 +82,7 @@ import {
SidebarContextMenu,
type SidebarContextMenuEntry,
} from "./SidebarContextMenu";
+import { ProjectFavicon } from "./ProjectFavicon";
import {
SidebarContent,
SidebarFooter,
@@ -462,8 +463,11 @@ const SidebarProjectSection = memo(function SidebarProjectSection(props: {
});
}}
>
-