Update files

This commit is contained in:
Mohamed Boudra
2026-01-26 20:38:39 +07:00
parent c233c99cd3
commit 637967fa14
9 changed files with 853 additions and 8 deletions

View File

@@ -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)}
>
<View style={styles.sectionHeaderLeft}>
{icon && (
<Image
source={{ uri: `data:${icon.mimeType};base64,${icon.data}` }}
style={styles.projectIcon}
/>
)}
<Text style={styles.sectionTitle} numberOfLines={1}>
{displayTitle}
</Text>
@@ -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",

View File

@@ -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<ProjectIcon | null> => {
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,
};
}

View File

@@ -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<ProjectIconResponse["payload"]> {
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
// ============================================================================

View File

@@ -326,7 +326,8 @@ export class AgentManager {
async resumeAgent(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
agentId?: string
agentId?: string,
timestamps?: { createdAt?: Date; updatedAt?: Date; lastUserMessageAt?: Date | null }
): Promise<ManagedAgent> {
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
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<ManagedAgent> {
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,

View File

@@ -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,
};
}

View File

@@ -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<SessionInboundMessage, { type: "project_icon_request" }>
): Promise<void> {
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
*/

View File

@@ -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<typeof HighlightedDiffRequestSchema
export type HighlightedDiffResponse = z.infer<typeof HighlightedDiffResponseSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema>;
export type ProjectIconResponse = z.infer<typeof ProjectIconResponseSchema>;
export type ProjectIcon = z.infer<typeof ProjectIconSchema>;
export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSchema>;
export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema>;
export type GitRepoInfoResponse = z.infer<typeof GitRepoInfoResponseSchema>;

View File

@@ -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"), "<svg></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();
});
});

View File

@@ -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<string | null> {
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<string>,
maxDepth: number,
currentDepth: number = 0
): Promise<string | null> {
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<string | null> {
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<string | null> {
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<ProjectIcon | null> {
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;
}
}