diff --git a/packages/app/src/components/grouped-agent-list.tsx b/packages/app/src/components/grouped-agent-list.tsx index b11b08bfc..274f02b23 100644 --- a/packages/app/src/components/grouped-agent-list.tsx +++ b/packages/app/src/components/grouped-agent-list.tsx @@ -3,6 +3,7 @@ import { Text, Pressable, Modal, + Image, } from "react-native"; import { useCallback, @@ -43,6 +44,7 @@ import { useSectionOrderStore, sortProjectsByStoredOrder, } from "@/stores/section-order-store"; +import { useProjectIconQuery } from "@/hooks/use-project-icon-query"; interface SectionData { key: string; @@ -96,6 +98,13 @@ function SectionHeader({ }); const checkout = checkoutQuery.data ?? null; + // Get project icon + const iconQuery = useProjectIconQuery({ + serverId: section.firstAgentServerId ?? "", + cwd: section.workingDir ?? "", + }); + const icon = iconQuery.icon; + // Derive display title: prefer repo name from remote URL, fallback to path-based name let displayTitle = section.title; if (checkout?.isGit && checkout.remoteUrl) { @@ -138,6 +147,12 @@ function SectionHeader({ onHoverOut={() => setIsHovered(false)} > + {icon && ( + + )} {displayTitle} @@ -586,6 +601,12 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: "flex-start", flex: 1, minWidth: 0, + gap: theme.spacing[2], + }, + projectIcon: { + width: 16, + height: 16, + borderRadius: theme.borderRadius.sm, }, sectionHeaderRight: { flexDirection: "row", diff --git a/packages/app/src/hooks/use-project-icon-query.ts b/packages/app/src/hooks/use-project-icon-query.ts new file mode 100644 index 000000000..6c3b94696 --- /dev/null +++ b/packages/app/src/hooks/use-project-icon-query.ts @@ -0,0 +1,44 @@ +import { useQuery } from "@tanstack/react-query"; +import { useSessionStore } from "@/stores/session-store"; +import type { ProjectIcon } from "@server/shared/messages"; + +export function projectIconQueryKey(serverId: string, cwd: string) { + return ["projectIcon", serverId, cwd] as const; +} + +interface UseProjectIconQueryOptions { + serverId: string; + cwd: string; +} + +export function useProjectIconQuery({ serverId, cwd }: UseProjectIconQueryOptions) { + const client = useSessionStore( + (state) => state.sessions[serverId]?.client ?? null + ); + const isConnected = useSessionStore( + (state) => state.sessions[serverId]?.connection.isConnected ?? false + ); + + const query = useQuery({ + queryKey: projectIconQueryKey(serverId, cwd), + queryFn: async (): Promise => { + if (!client) { + throw new Error("Daemon client not available"); + } + const result = await client.requestProjectIcon(cwd); + return result.icon; + }, + enabled: !!client && isConnected && !!cwd, + staleTime: Infinity, + gcTime: 1000 * 60 * 60, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + + return { + icon: query.data ?? null, + isLoading: query.isLoading, + isError: query.isError, + }; +} diff --git a/packages/server/src/client/daemon-client-v2.ts b/packages/server/src/client/daemon-client-v2.ts index eace32e65..3c2572e88 100644 --- a/packages/server/src/client/daemon-client-v2.ts +++ b/packages/server/src/client/daemon-client-v2.ts @@ -31,6 +31,7 @@ import type { CheckoutPrStatusResponse, PaseoWorktreeListResponse, PaseoWorktreeArchiveResponse, + ProjectIconResponse, ListCommandsResponse, ExecuteCommandResponse, ListVoiceConversationsResponseMessage, @@ -1599,6 +1600,33 @@ export class DaemonClientV2 { return response; } + async requestProjectIcon( + cwd: string, + requestId?: string + ): Promise { + const resolvedRequestId = this.createRequestId(requestId); + const message = SessionInboundMessageSchema.parse({ + type: "project_icon_request", + cwd, + requestId: resolvedRequestId, + }); + const response = this.waitFor( + (msg) => { + if (msg.type !== "project_icon_response") { + return null; + } + if (msg.payload.requestId !== resolvedRequestId) { + return null; + } + return msg.payload; + }, + 10000, + { skipQueue: true } + ); + await this.sendSessionMessageOrThrow(message); + return response; + } + // ============================================================================ // Provider Models / Commands // ============================================================================ diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index bf4745fd8..3b68aa4c8 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -326,7 +326,8 @@ export class AgentManager { async resumeAgent( handle: AgentPersistenceHandle, overrides?: Partial, - agentId?: string + agentId?: string, + timestamps?: { createdAt?: Date; updatedAt?: Date; lastUserMessageAt?: Date | null } ): Promise { const metadata = (handle.metadata ?? {}) as Partial; const mergedConfig = { @@ -344,7 +345,8 @@ export class AgentManager { return this.registerSession( session, normalizedConfig, - agentId ?? this.idFactory() + agentId ?? this.idFactory(), + timestamps ); } @@ -792,7 +794,8 @@ export class AgentManager { private async registerSession( session: AgentSession, config: AgentSessionConfig, - agentId: string + agentId: string, + timestamps?: { createdAt?: Date; updatedAt?: Date; lastUserMessageAt?: Date | null } ): Promise { if (this.agents.has(agentId)) { throw new Error(`Agent with id ${agentId} already exists`); @@ -801,6 +804,7 @@ export class AgentManager { // Inform the session of its managed agent ID for MCP parent-child relationships session.setManagedAgentId?.(agentId); + const now = new Date(); const managed = { id: agentId, provider: config.provider, @@ -810,8 +814,8 @@ export class AgentManager { config, runtimeInfo: undefined, lifecycle: "initializing", - createdAt: new Date(), - updatedAt: new Date(), + createdAt: timestamps?.createdAt ?? now, + updatedAt: timestamps?.updatedAt ?? now, availableModes: [], currentModeId: null, pendingPermissions: new Map(), @@ -819,7 +823,7 @@ export class AgentManager { timeline: [], persistence: session.describePersistence(), historyPrimed: false, - lastUserMessageAt: null, + lastUserMessageAt: timestamps?.lastUserMessageAt ?? null, attention: { requiresAttention: false }, parentAgentId: config.parentAgentId, internal: config.internal ?? false, diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index 46daa80dc..8b7c6be7b 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -74,3 +74,13 @@ export function buildSessionConfig( extra: overrides.extra, }; } + +export function extractTimestamps( + record: StoredAgentRecord +): { createdAt: Date; updatedAt: Date; lastUserMessageAt: Date | null } { + return { + createdAt: new Date(record.createdAt), + updatedAt: new Date(record.lastActivityAt ?? record.updatedAt), + lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null, + }; +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index d7b9f3e7c..9dd59656e 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -44,6 +44,7 @@ import type { VoiceConversationStore } from "./voice-conversation-store.js"; import { buildConfigOverrides, buildSessionConfig, + extractTimestamps, } from "./persistence-hooks.js"; import { experimental_createMCPClient } from "ai"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; @@ -101,6 +102,7 @@ import { createPullRequest, getPullRequestStatus, } from "../utils/checkout-git.js"; +import { getProjectIcon } from "../utils/project-icon.js"; import { expandTilde } from "../utils/path.js"; import type pino from "pino"; @@ -721,7 +723,8 @@ export class Session { snapshot = await this.agentManager.resumeAgent( handle, buildConfigOverrides(record), - agentId + agentId, + extractTimestamps(record) ); } else { const config = buildSessionConfig(record); @@ -935,6 +938,10 @@ export class Session { await this.handleFileExplorerRequest(msg); break; + case "project_icon_request": + await this.handleProjectIconRequest(msg); + break; + case "file_download_token_request": await this.handleFileDownloadTokenRequest(msg); break; @@ -1952,7 +1959,8 @@ export class Session { snapshot = await this.agentManager.resumeAgent( handle, buildConfigOverrides(record), - agentId + agentId, + extractTimestamps(record) ); } await this.agentManager.primeAgentHistory(agentId); @@ -3836,6 +3844,38 @@ export class Session { } } + /** + * Handle project icon request for a given cwd + */ + private async handleProjectIconRequest( + request: Extract + ): Promise { + const { cwd, requestId } = request; + + try { + const icon = await getProjectIcon(cwd); + this.emit({ + type: "project_icon_response", + payload: { + cwd, + icon, + error: null, + requestId, + }, + }); + } catch (error: any) { + this.emit({ + type: "project_icon_response", + payload: { + cwd, + icon: null, + error: error.message, + requestId, + }, + }); + } + } + /** * Handle file download token request scoped to an agent's cwd */ diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index a59258f64..b10f4ab4b 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -616,6 +616,12 @@ export const FileExplorerRequestSchema = z.object({ requestId: z.string(), }); +export const ProjectIconRequestSchema = z.object({ + type: z.literal("project_icon_request"), + cwd: z.string(), + requestId: z.string(), +}); + export const FileDownloadTokenRequestSchema = z.object({ type: z.literal("file_download_token_request"), agentId: z.string(), @@ -745,6 +751,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ PaseoWorktreeArchiveRequestSchema, HighlightedDiffRequestSchema, FileExplorerRequestSchema, + ProjectIconRequestSchema, FileDownloadTokenRequestSchema, GitRepoInfoRequestMessageSchema, ClearAgentAttentionMessageSchema, @@ -1221,6 +1228,21 @@ export const FileExplorerResponseSchema = z.object({ }), }); +const ProjectIconSchema = z.object({ + data: z.string(), + mimeType: z.string(), +}); + +export const ProjectIconResponseSchema = z.object({ + type: z.literal("project_icon_response"), + payload: z.object({ + cwd: z.string(), + icon: ProjectIconSchema.nullable(), + error: z.string().nullable(), + requestId: z.string(), + }), +}); + export const FileDownloadTokenResponseSchema = z.object({ type: z.literal("file_download_token_response"), payload: z.object({ @@ -1406,6 +1428,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ PaseoWorktreeArchiveResponseSchema, HighlightedDiffResponseSchema, FileExplorerResponseSchema, + ProjectIconResponseSchema, FileDownloadTokenResponseSchema, GitRepoInfoResponseSchema, ListProviderModelsResponseMessageSchema, @@ -1499,6 +1522,9 @@ export type HighlightedDiffRequest = z.infer; export type FileExplorerRequest = z.infer; export type FileExplorerResponse = z.infer; +export type ProjectIconRequest = z.infer; +export type ProjectIconResponse = z.infer; +export type ProjectIcon = z.infer; export type FileDownloadTokenRequest = z.infer; export type FileDownloadTokenResponse = z.infer; export type GitRepoInfoResponse = z.infer; diff --git a/packages/server/src/utils/project-icon.test.ts b/packages/server/src/utils/project-icon.test.ts new file mode 100644 index 000000000..9a6c5d5e0 --- /dev/null +++ b/packages/server/src/utils/project-icon.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, realpathSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { findProjectIcon, getProjectIcon, ICON_PATTERNS, PRIORITY_DIRS, IGNORED_DIRS } from "./project-icon.js"; + +function createTempDir(): string { + return realpathSync(mkdtempSync(join(tmpdir(), "project-icon-test-"))); +} + +describe("findProjectIcon", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + describe("ICON_PATTERNS", () => { + it("includes common favicon patterns", () => { + expect(ICON_PATTERNS).toContain("favicon.ico"); + expect(ICON_PATTERNS).toContain("favicon.png"); + expect(ICON_PATTERNS).toContain("favicon.svg"); + }); + + it("includes app icon patterns", () => { + expect(ICON_PATTERNS).toContain("icon.png"); + expect(ICON_PATTERNS).toContain("icon.svg"); + expect(ICON_PATTERNS).toContain("app-icon.png"); + }); + + it("includes logo patterns", () => { + expect(ICON_PATTERNS).toContain("logo.png"); + expect(ICON_PATTERNS).toContain("logo.svg"); + }); + }); + + describe("PRIORITY_DIRS", () => { + it("includes common asset directories", () => { + expect(PRIORITY_DIRS).toContain("public"); + expect(PRIORITY_DIRS).toContain("static"); + expect(PRIORITY_DIRS).toContain("assets"); + }); + }); + + describe("IGNORED_DIRS", () => { + it("includes common ignored directories", () => { + expect(IGNORED_DIRS).toContain(".git"); + expect(IGNORED_DIRS).toContain("node_modules"); + expect(IGNORED_DIRS).toContain("dist"); + expect(IGNORED_DIRS).toContain("build"); + }); + }); + + it("returns null when no icon is found", async () => { + const result = await findProjectIcon(tempDir); + expect(result).toBeNull(); + }); + + it("finds favicon.ico in root directory", async () => { + writeFileSync(join(tempDir, "favicon.ico"), "icon content"); + + const result = await findProjectIcon(tempDir); + expect(result).toBe(join(tempDir, "favicon.ico")); + }); + + it("finds favicon.png in root directory", async () => { + writeFileSync(join(tempDir, "favicon.png"), "icon content"); + + const result = await findProjectIcon(tempDir); + expect(result).toBe(join(tempDir, "favicon.png")); + }); + + it("finds icon in public directory (priority dir)", async () => { + mkdirSync(join(tempDir, "public")); + writeFileSync(join(tempDir, "public", "favicon.ico"), "icon content"); + + const result = await findProjectIcon(tempDir); + expect(result).toBe(join(tempDir, "public", "favicon.ico")); + }); + + it("finds icon in static directory (priority dir)", async () => { + mkdirSync(join(tempDir, "static")); + writeFileSync(join(tempDir, "static", "favicon.svg"), "icon content"); + + const result = await findProjectIcon(tempDir); + expect(result).toBe(join(tempDir, "static", "favicon.svg")); + }); + + it("finds icon in assets directory (priority dir)", async () => { + mkdirSync(join(tempDir, "assets")); + writeFileSync(join(tempDir, "assets", "logo.png"), "icon content"); + + const result = await findProjectIcon(tempDir); + expect(result).toBe(join(tempDir, "assets", "logo.png")); + }); + + it("prioritizes favicon over logo", async () => { + writeFileSync(join(tempDir, "favicon.ico"), "favicon"); + writeFileSync(join(tempDir, "logo.png"), "logo"); + + const result = await findProjectIcon(tempDir); + expect(result).toBe(join(tempDir, "favicon.ico")); + }); + + it("prioritizes priority dirs over root", async () => { + writeFileSync(join(tempDir, "logo.png"), "root logo"); + mkdirSync(join(tempDir, "public")); + writeFileSync(join(tempDir, "public", "favicon.ico"), "public favicon"); + + const result = await findProjectIcon(tempDir); + expect(result).toBe(join(tempDir, "public", "favicon.ico")); + }); + + it("ignores files in .git directory", async () => { + mkdirSync(join(tempDir, ".git")); + writeFileSync(join(tempDir, ".git", "favicon.ico"), "git icon"); + + const result = await findProjectIcon(tempDir); + expect(result).toBeNull(); + }); + + it("ignores files in node_modules directory", async () => { + mkdirSync(join(tempDir, "node_modules")); + writeFileSync(join(tempDir, "node_modules", "favicon.ico"), "node icon"); + + const result = await findProjectIcon(tempDir); + expect(result).toBeNull(); + }); + + it("ignores files in dist directory", async () => { + mkdirSync(join(tempDir, "dist")); + writeFileSync(join(tempDir, "dist", "favicon.ico"), "dist icon"); + + const result = await findProjectIcon(tempDir); + expect(result).toBeNull(); + }); + + it("finds icon in nested priority directory", async () => { + mkdirSync(join(tempDir, "public", "images"), { recursive: true }); + writeFileSync(join(tempDir, "public", "images", "favicon.png"), "nested icon"); + + const result = await findProjectIcon(tempDir); + expect(result).toBe(join(tempDir, "public", "images", "favicon.png")); + }); + + it("finds apple-touch-icon.png", async () => { + writeFileSync(join(tempDir, "apple-touch-icon.png"), "apple icon"); + + const result = await findProjectIcon(tempDir); + expect(result).toBe(join(tempDir, "apple-touch-icon.png")); + }); + + it("finds icon-*.png patterns", async () => { + writeFileSync(join(tempDir, "icon-192.png"), "192 icon"); + + const result = await findProjectIcon(tempDir); + expect(result).toBe(join(tempDir, "icon-192.png")); + }); + + it("handles non-existent directory gracefully", async () => { + const result = await findProjectIcon(join(tempDir, "nonexistent")); + expect(result).toBeNull(); + }); + + it("returns the first match when multiple icons exist in same location", async () => { + writeFileSync(join(tempDir, "favicon.ico"), "ico"); + writeFileSync(join(tempDir, "favicon.png"), "png"); + writeFileSync(join(tempDir, "favicon.svg"), "svg"); + + const result = await findProjectIcon(tempDir); + // Should return the first one based on pattern order (favicon.ico comes first) + expect(result).toBe(join(tempDir, "favicon.ico")); + }); +}); + +describe("getProjectIcon", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + // Valid 1x1 PNG (square) + const squarePng = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature + 0x00, 0x00, 0x00, 0x0d, // IHDR chunk length + 0x49, 0x48, 0x44, 0x52, // IHDR + 0x00, 0x00, 0x00, 0x01, // width: 1 + 0x00, 0x00, 0x00, 0x01, // height: 1 + 0x08, 0x02, // bit depth, color type + 0x00, 0x00, 0x00, // compression, filter, interlace + 0x90, 0x77, 0x53, 0xde, // CRC + 0x00, 0x00, 0x00, 0x0c, // IDAT chunk length + 0x49, 0x44, 0x41, 0x54, // IDAT + 0x08, 0xd7, 0x63, 0xf8, 0xff, 0xff, 0xff, 0x00, 0x05, 0xfe, 0x02, 0xfe, // data + 0xa3, 0x6c, 0x47, 0x9f, // CRC + 0x00, 0x00, 0x00, 0x00, // IEND chunk length + 0x49, 0x45, 0x4e, 0x44, // IEND + 0xae, 0x42, 0x60, 0x82, // CRC + ]); + + // Valid 2x1 PNG (non-square) + const nonSquarePng = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature + 0x00, 0x00, 0x00, 0x0d, // IHDR chunk length + 0x49, 0x48, 0x44, 0x52, // IHDR + 0x00, 0x00, 0x00, 0x02, // width: 2 + 0x00, 0x00, 0x00, 0x01, // height: 1 + 0x08, 0x02, // bit depth, color type + 0x00, 0x00, 0x00, // compression, filter, interlace + 0x00, 0x00, 0x00, 0x00, // CRC (not validated) + ]); + + it("returns icon data for square PNG", async () => { + writeFileSync(join(tempDir, "favicon.png"), squarePng); + + const result = await getProjectIcon(tempDir); + expect(result).not.toBeNull(); + expect(result?.mimeType).toBe("image/png"); + expect(result?.data).toBe(squarePng.toString("base64")); + }); + + it("returns null for non-square PNG", async () => { + writeFileSync(join(tempDir, "favicon.png"), nonSquarePng); + + const result = await getProjectIcon(tempDir); + expect(result).toBeNull(); + }); + + it("returns icon data for ICO files (assumed square)", async () => { + writeFileSync(join(tempDir, "favicon.ico"), "ico content"); + + const result = await getProjectIcon(tempDir); + expect(result).not.toBeNull(); + expect(result?.mimeType).toBe("image/x-icon"); + }); + + it("returns icon data for SVG files (assumed square)", async () => { + writeFileSync(join(tempDir, "favicon.svg"), ""); + + const result = await getProjectIcon(tempDir); + expect(result).not.toBeNull(); + expect(result?.mimeType).toBe("image/svg+xml"); + }); + + it("returns null for files over 32KB", async () => { + const largeContent = Buffer.alloc(33 * 1024, 0); + writeFileSync(join(tempDir, "favicon.ico"), largeContent); + + const result = await getProjectIcon(tempDir); + expect(result).toBeNull(); + }); + + it("returns null when no icon is found", async () => { + const result = await getProjectIcon(tempDir); + expect(result).toBeNull(); + }); +}); diff --git a/packages/server/src/utils/project-icon.ts b/packages/server/src/utils/project-icon.ts new file mode 100644 index 000000000..24d416239 --- /dev/null +++ b/packages/server/src/utils/project-icon.ts @@ -0,0 +1,406 @@ +import { readdir, readFile, stat } from "fs/promises"; +import { extname, join } from "path"; + +/** + * Icon file patterns to search for, in priority order. + * Patterns starting with '*' are glob patterns (e.g., icon-*.png). + */ +export const ICON_PATTERNS = [ + "favicon.ico", + "favicon.png", + "favicon.svg", + "icon.png", + "icon.svg", + "app-icon.png", + "app-icon.svg", + "apple-touch-icon.png", + "icon-*.png", + "logo.png", + "logo.svg", +]; + +/** + * Directories to search first (in priority order). + */ +export const PRIORITY_DIRS = ["public", "static", "assets", "images", "img"]; + +/** + * Directories to ignore during search. + */ +export const IGNORED_DIRS = [ + ".git", + "node_modules", + "dist", + "build", + ".next", + ".nuxt", + ".output", + "coverage", + ".cache", + "vendor", + "src", + "lib", + "test", + "tests", + "__tests__", +]; + +export interface ProjectIcon { + data: string; + mimeType: string; +} + +const MAX_ICON_SIZE = 32 * 1024; // 32KB max + +interface ImageDimensions { + width: number; + height: number; +} + +function getPngDimensions(buffer: Buffer): ImageDimensions | null { + // PNG header: 89 50 4E 47 0D 0A 1A 0A + if (buffer.length < 24) return null; + if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) { + return null; + } + // Width and height are at bytes 16-19 and 20-23 (big endian) + const width = buffer.readUInt32BE(16); + const height = buffer.readUInt32BE(20); + return { width, height }; +} + +function getJpegDimensions(buffer: Buffer): ImageDimensions | null { + // JPEG starts with FF D8 FF + if (buffer.length < 4) return null; + if (buffer[0] !== 0xff || buffer[1] !== 0xd8) return null; + + let offset = 2; + while (offset < buffer.length - 8) { + if (buffer[offset] !== 0xff) { + offset++; + continue; + } + + const marker = buffer[offset + 1]; + // SOF0-SOF2 markers contain dimensions + if (marker >= 0xc0 && marker <= 0xc2) { + const height = buffer.readUInt16BE(offset + 5); + const width = buffer.readUInt16BE(offset + 7); + return { width, height }; + } + + // Skip to next marker + const length = buffer.readUInt16BE(offset + 2); + offset += 2 + length; + } + return null; +} + +function getGifDimensions(buffer: Buffer): ImageDimensions | null { + // GIF header: GIF87a or GIF89a + if (buffer.length < 10) return null; + if (buffer[0] !== 0x47 || buffer[1] !== 0x49 || buffer[2] !== 0x46) return null; + // Width and height at bytes 6-7 and 8-9 (little endian) + const width = buffer.readUInt16LE(6); + const height = buffer.readUInt16LE(8); + return { width, height }; +} + +function getWebpDimensions(buffer: Buffer): ImageDimensions | null { + // WEBP: RIFF....WEBP + if (buffer.length < 30) return null; + if (buffer.toString("ascii", 0, 4) !== "RIFF") return null; + if (buffer.toString("ascii", 8, 12) !== "WEBP") return null; + + const chunkType = buffer.toString("ascii", 12, 16); + if (chunkType === "VP8 ") { + // Lossy format - dimensions at offset 26-27 and 28-29 + const width = buffer.readUInt16LE(26) & 0x3fff; + const height = buffer.readUInt16LE(28) & 0x3fff; + return { width, height }; + } else if (chunkType === "VP8L") { + // Lossless format + const bits = buffer.readUInt32LE(21); + const width = (bits & 0x3fff) + 1; + const height = ((bits >> 14) & 0x3fff) + 1; + return { width, height }; + } + return null; +} + +function getImageDimensions(buffer: Buffer, mimeType: string): ImageDimensions | null { + switch (mimeType) { + case "image/png": + return getPngDimensions(buffer); + case "image/jpeg": + return getJpegDimensions(buffer); + case "image/gif": + return getGifDimensions(buffer); + case "image/webp": + return getWebpDimensions(buffer); + case "image/x-icon": + // ICO files are typically square, trust them + return { width: 1, height: 1 }; + case "image/svg+xml": + // SVG can be any aspect ratio but icons are typically square, trust them + return { width: 1, height: 1 }; + default: + return null; + } +} + +function isSquareImage(buffer: Buffer, mimeType: string): boolean { + const dimensions = getImageDimensions(buffer, mimeType); + if (!dimensions) return false; + return dimensions.width === dimensions.height; +} + +function getMimeType(filename: string): string { + const ext = extname(filename).toLowerCase(); + switch (ext) { + case ".ico": + return "image/x-icon"; + case ".png": + return "image/png"; + case ".svg": + return "image/svg+xml"; + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".gif": + return "image/gif"; + case ".webp": + return "image/webp"; + default: + return "application/octet-stream"; + } +} + +function matchesPattern(filename: string, pattern: string): boolean { + if (pattern.includes("*")) { + // Convert glob pattern to regex + const regexPattern = pattern + .replace(/\./g, "\\.") + .replace(/\*/g, ".*"); + return new RegExp(`^${regexPattern}$`).test(filename); + } + return filename === pattern; +} + +async function findIconInDir( + dir: string, + patterns: string[] +): Promise { + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + return null; + } + + // Check each pattern in order of priority + for (const pattern of patterns) { + for (const entry of entries) { + if (matchesPattern(entry, pattern)) { + const fullPath = join(dir, entry); + try { + const stats = await stat(fullPath); + if (stats.isFile()) { + return fullPath; + } + } catch { + // File may have been deleted, continue + } + } + } + } + + return null; +} + +async function searchDirRecursively( + dir: string, + patterns: string[], + ignoredDirs: Set, + maxDepth: number, + currentDepth: number = 0 +): Promise { + if (currentDepth > maxDepth) { + return null; + } + + // First check this directory for icons + const found = await findIconInDir(dir, patterns); + if (found) { + return found; + } + + // Then recurse into subdirectories + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + return null; + } + + for (const entry of entries) { + if (ignoredDirs.has(entry)) { + continue; + } + + const fullPath = join(dir, entry); + try { + const stats = await stat(fullPath); + if (stats.isDirectory()) { + const result = await searchDirRecursively( + fullPath, + patterns, + ignoredDirs, + maxDepth, + currentDepth + 1 + ); + if (result) { + return result; + } + } + } catch { + // Directory may be inaccessible, continue + } + } + + return null; +} + +/** + * Find a project icon/favicon in the given directory. + * Searches priority directories first, then falls back to scanning the root. + * + * @param projectDir - The root directory of the project to search + * @param maxDepth - Maximum depth to search (default: 3) + * @returns The absolute path to the found icon, or null if not found + */ +export async function findProjectIcon( + projectDir: string, + maxDepth: number = 3 +): Promise { + const ignoredDirsSet = new Set(IGNORED_DIRS); + + // First search priority directories + for (const priorityDir of PRIORITY_DIRS) { + const priorityPath = join(projectDir, priorityDir); + try { + const stats = await stat(priorityPath); + if (stats.isDirectory()) { + const result = await searchDirRecursively( + priorityPath, + ICON_PATTERNS, + ignoredDirsSet, + maxDepth - 1 + ); + if (result) { + return result; + } + } + } catch { + // Directory doesn't exist, continue + } + } + + // Then search root and any other non-priority directories + const found = await findDirRecursively(projectDir); + if (found) { + return found; + } + + return null; +} + +async function findDirRecursively( + dir: string, + maxDepth: number = 2, + currentDepth: number = 0 +): Promise { + const ignoredDirsSet = new Set(IGNORED_DIRS); + const priorityDirsSet = new Set(PRIORITY_DIRS); + + if (currentDepth > maxDepth) { + return null; + } + + // Check root for icons + const found = await findIconInDir(dir, ICON_PATTERNS); + if (found) { + return found; + } + + // Don't recurse further from root - we already searched priority dirs + if (currentDepth > 0) { + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + return null; + } + + for (const entry of entries) { + if (ignoredDirsSet.has(entry) || priorityDirsSet.has(entry)) { + continue; + } + + const fullPath = join(dir, entry); + try { + const stats = await stat(fullPath); + if (stats.isDirectory()) { + const result = await findDirRecursively( + fullPath, + maxDepth, + currentDepth + 1 + ); + if (result) { + return result; + } + } + } catch { + // Continue + } + } + } + + return null; +} + +/** + * Find and read a project icon/favicon, returning it as base64. + * Only returns square icons smaller than MAX_ICON_SIZE (32KB). + * + * @param projectDir - The root directory of the project to search + * @returns The icon data with mime type, or null if not found + */ +export async function getProjectIcon( + projectDir: string +): Promise { + const iconPath = await findProjectIcon(projectDir); + if (!iconPath) { + return null; + } + + try { + const stats = await stat(iconPath); + if (stats.size > MAX_ICON_SIZE) { + return null; + } + + const buffer = await readFile(iconPath); + const mimeType = getMimeType(iconPath); + + // Only return square images + if (!isSquareImage(buffer, mimeType)) { + return null; + } + + const data = buffer.toString("base64"); + return { data, mimeType }; + } catch { + return null; + } +}