Update files
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Pressable,
|
Pressable,
|
||||||
Modal,
|
Modal,
|
||||||
|
Image,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -43,6 +44,7 @@ import {
|
|||||||
useSectionOrderStore,
|
useSectionOrderStore,
|
||||||
sortProjectsByStoredOrder,
|
sortProjectsByStoredOrder,
|
||||||
} from "@/stores/section-order-store";
|
} from "@/stores/section-order-store";
|
||||||
|
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
|
||||||
|
|
||||||
interface SectionData {
|
interface SectionData {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -96,6 +98,13 @@ function SectionHeader({
|
|||||||
});
|
});
|
||||||
const checkout = checkoutQuery.data ?? null;
|
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
|
// Derive display title: prefer repo name from remote URL, fallback to path-based name
|
||||||
let displayTitle = section.title;
|
let displayTitle = section.title;
|
||||||
if (checkout?.isGit && checkout.remoteUrl) {
|
if (checkout?.isGit && checkout.remoteUrl) {
|
||||||
@@ -138,6 +147,12 @@ function SectionHeader({
|
|||||||
onHoverOut={() => setIsHovered(false)}
|
onHoverOut={() => setIsHovered(false)}
|
||||||
>
|
>
|
||||||
<View style={styles.sectionHeaderLeft}>
|
<View style={styles.sectionHeaderLeft}>
|
||||||
|
{icon && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: `data:${icon.mimeType};base64,${icon.data}` }}
|
||||||
|
style={styles.projectIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Text style={styles.sectionTitle} numberOfLines={1}>
|
<Text style={styles.sectionTitle} numberOfLines={1}>
|
||||||
{displayTitle}
|
{displayTitle}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -586,6 +601,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
justifyContent: "flex-start",
|
justifyContent: "flex-start",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
projectIcon: {
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: theme.borderRadius.sm,
|
||||||
},
|
},
|
||||||
sectionHeaderRight: {
|
sectionHeaderRight: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import type {
|
|||||||
CheckoutPrStatusResponse,
|
CheckoutPrStatusResponse,
|
||||||
PaseoWorktreeListResponse,
|
PaseoWorktreeListResponse,
|
||||||
PaseoWorktreeArchiveResponse,
|
PaseoWorktreeArchiveResponse,
|
||||||
|
ProjectIconResponse,
|
||||||
ListCommandsResponse,
|
ListCommandsResponse,
|
||||||
ExecuteCommandResponse,
|
ExecuteCommandResponse,
|
||||||
ListVoiceConversationsResponseMessage,
|
ListVoiceConversationsResponseMessage,
|
||||||
@@ -1599,6 +1600,33 @@ export class DaemonClientV2 {
|
|||||||
return response;
|
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
|
// Provider Models / Commands
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -326,7 +326,8 @@ export class AgentManager {
|
|||||||
async resumeAgent(
|
async resumeAgent(
|
||||||
handle: AgentPersistenceHandle,
|
handle: AgentPersistenceHandle,
|
||||||
overrides?: Partial<AgentSessionConfig>,
|
overrides?: Partial<AgentSessionConfig>,
|
||||||
agentId?: string
|
agentId?: string,
|
||||||
|
timestamps?: { createdAt?: Date; updatedAt?: Date; lastUserMessageAt?: Date | null }
|
||||||
): Promise<ManagedAgent> {
|
): Promise<ManagedAgent> {
|
||||||
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
||||||
const mergedConfig = {
|
const mergedConfig = {
|
||||||
@@ -344,7 +345,8 @@ export class AgentManager {
|
|||||||
return this.registerSession(
|
return this.registerSession(
|
||||||
session,
|
session,
|
||||||
normalizedConfig,
|
normalizedConfig,
|
||||||
agentId ?? this.idFactory()
|
agentId ?? this.idFactory(),
|
||||||
|
timestamps
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -792,7 +794,8 @@ export class AgentManager {
|
|||||||
private async registerSession(
|
private async registerSession(
|
||||||
session: AgentSession,
|
session: AgentSession,
|
||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
agentId: string
|
agentId: string,
|
||||||
|
timestamps?: { createdAt?: Date; updatedAt?: Date; lastUserMessageAt?: Date | null }
|
||||||
): Promise<ManagedAgent> {
|
): Promise<ManagedAgent> {
|
||||||
if (this.agents.has(agentId)) {
|
if (this.agents.has(agentId)) {
|
||||||
throw new Error(`Agent with id ${agentId} already exists`);
|
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
|
// Inform the session of its managed agent ID for MCP parent-child relationships
|
||||||
session.setManagedAgentId?.(agentId);
|
session.setManagedAgentId?.(agentId);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
const managed = {
|
const managed = {
|
||||||
id: agentId,
|
id: agentId,
|
||||||
provider: config.provider,
|
provider: config.provider,
|
||||||
@@ -810,8 +814,8 @@ export class AgentManager {
|
|||||||
config,
|
config,
|
||||||
runtimeInfo: undefined,
|
runtimeInfo: undefined,
|
||||||
lifecycle: "initializing",
|
lifecycle: "initializing",
|
||||||
createdAt: new Date(),
|
createdAt: timestamps?.createdAt ?? now,
|
||||||
updatedAt: new Date(),
|
updatedAt: timestamps?.updatedAt ?? now,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
pendingPermissions: new Map(),
|
pendingPermissions: new Map(),
|
||||||
@@ -819,7 +823,7 @@ export class AgentManager {
|
|||||||
timeline: [],
|
timeline: [],
|
||||||
persistence: session.describePersistence(),
|
persistence: session.describePersistence(),
|
||||||
historyPrimed: false,
|
historyPrimed: false,
|
||||||
lastUserMessageAt: null,
|
lastUserMessageAt: timestamps?.lastUserMessageAt ?? null,
|
||||||
attention: { requiresAttention: false },
|
attention: { requiresAttention: false },
|
||||||
parentAgentId: config.parentAgentId,
|
parentAgentId: config.parentAgentId,
|
||||||
internal: config.internal ?? false,
|
internal: config.internal ?? false,
|
||||||
|
|||||||
@@ -74,3 +74,13 @@ export function buildSessionConfig(
|
|||||||
extra: overrides.extra,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import type { VoiceConversationStore } from "./voice-conversation-store.js";
|
|||||||
import {
|
import {
|
||||||
buildConfigOverrides,
|
buildConfigOverrides,
|
||||||
buildSessionConfig,
|
buildSessionConfig,
|
||||||
|
extractTimestamps,
|
||||||
} from "./persistence-hooks.js";
|
} from "./persistence-hooks.js";
|
||||||
import { experimental_createMCPClient } from "ai";
|
import { experimental_createMCPClient } from "ai";
|
||||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||||
@@ -101,6 +102,7 @@ import {
|
|||||||
createPullRequest,
|
createPullRequest,
|
||||||
getPullRequestStatus,
|
getPullRequestStatus,
|
||||||
} from "../utils/checkout-git.js";
|
} from "../utils/checkout-git.js";
|
||||||
|
import { getProjectIcon } from "../utils/project-icon.js";
|
||||||
import { expandTilde } from "../utils/path.js";
|
import { expandTilde } from "../utils/path.js";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
|
|
||||||
@@ -721,7 +723,8 @@ export class Session {
|
|||||||
snapshot = await this.agentManager.resumeAgent(
|
snapshot = await this.agentManager.resumeAgent(
|
||||||
handle,
|
handle,
|
||||||
buildConfigOverrides(record),
|
buildConfigOverrides(record),
|
||||||
agentId
|
agentId,
|
||||||
|
extractTimestamps(record)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const config = buildSessionConfig(record);
|
const config = buildSessionConfig(record);
|
||||||
@@ -935,6 +938,10 @@ export class Session {
|
|||||||
await this.handleFileExplorerRequest(msg);
|
await this.handleFileExplorerRequest(msg);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "project_icon_request":
|
||||||
|
await this.handleProjectIconRequest(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
case "file_download_token_request":
|
case "file_download_token_request":
|
||||||
await this.handleFileDownloadTokenRequest(msg);
|
await this.handleFileDownloadTokenRequest(msg);
|
||||||
break;
|
break;
|
||||||
@@ -1952,7 +1959,8 @@ export class Session {
|
|||||||
snapshot = await this.agentManager.resumeAgent(
|
snapshot = await this.agentManager.resumeAgent(
|
||||||
handle,
|
handle,
|
||||||
buildConfigOverrides(record),
|
buildConfigOverrides(record),
|
||||||
agentId
|
agentId,
|
||||||
|
extractTimestamps(record)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.agentManager.primeAgentHistory(agentId);
|
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
|
* Handle file download token request scoped to an agent's cwd
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -616,6 +616,12 @@ export const FileExplorerRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
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({
|
export const FileDownloadTokenRequestSchema = z.object({
|
||||||
type: z.literal("file_download_token_request"),
|
type: z.literal("file_download_token_request"),
|
||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
@@ -745,6 +751,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
PaseoWorktreeArchiveRequestSchema,
|
PaseoWorktreeArchiveRequestSchema,
|
||||||
HighlightedDiffRequestSchema,
|
HighlightedDiffRequestSchema,
|
||||||
FileExplorerRequestSchema,
|
FileExplorerRequestSchema,
|
||||||
|
ProjectIconRequestSchema,
|
||||||
FileDownloadTokenRequestSchema,
|
FileDownloadTokenRequestSchema,
|
||||||
GitRepoInfoRequestMessageSchema,
|
GitRepoInfoRequestMessageSchema,
|
||||||
ClearAgentAttentionMessageSchema,
|
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({
|
export const FileDownloadTokenResponseSchema = z.object({
|
||||||
type: z.literal("file_download_token_response"),
|
type: z.literal("file_download_token_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -1406,6 +1428,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
PaseoWorktreeArchiveResponseSchema,
|
PaseoWorktreeArchiveResponseSchema,
|
||||||
HighlightedDiffResponseSchema,
|
HighlightedDiffResponseSchema,
|
||||||
FileExplorerResponseSchema,
|
FileExplorerResponseSchema,
|
||||||
|
ProjectIconResponseSchema,
|
||||||
FileDownloadTokenResponseSchema,
|
FileDownloadTokenResponseSchema,
|
||||||
GitRepoInfoResponseSchema,
|
GitRepoInfoResponseSchema,
|
||||||
ListProviderModelsResponseMessageSchema,
|
ListProviderModelsResponseMessageSchema,
|
||||||
@@ -1499,6 +1522,9 @@ export type HighlightedDiffRequest = z.infer<typeof HighlightedDiffRequestSchema
|
|||||||
export type HighlightedDiffResponse = z.infer<typeof HighlightedDiffResponseSchema>;
|
export type HighlightedDiffResponse = z.infer<typeof HighlightedDiffResponseSchema>;
|
||||||
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
||||||
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
|
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 FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSchema>;
|
||||||
export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema>;
|
export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema>;
|
||||||
export type GitRepoInfoResponse = z.infer<typeof GitRepoInfoResponseSchema>;
|
export type GitRepoInfoResponse = z.infer<typeof GitRepoInfoResponseSchema>;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user