Update files

This commit is contained in:
Mohamed Boudra
2026-01-27 13:06:01 +07:00
parent 637967fa14
commit 766585d1cb
10 changed files with 249 additions and 279 deletions

View File

@@ -171,7 +171,7 @@ export function AgentList({
void queryClient.prefetchQuery({
queryKey,
queryFn: async () => await client.getCheckoutStatus(agent.id),
queryFn: async () => await client.getCheckoutStatus(agent.id, { cwd: agent.cwd }),
staleTime: CHECKOUT_STATUS_STALE_TIME,
});
}

View File

@@ -362,7 +362,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const client = useSessionStore(
(state) => state.sessions[serverId]?.client ?? null
);
const [diffMode, setDiffMode] = useState<"uncommitted" | "base">("uncommitted");
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [actionStatus, setActionStatus] = useState<string | null>(null);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
@@ -375,6 +375,12 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
status?.error?.message ??
(isStatusError && statusError instanceof Error ? statusError.message : null);
const baseRef = gitStatus?.baseRef ?? undefined;
// Auto-select diff mode based on state: uncommitted when dirty, base when clean
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
const autoDiffMode = hasUncommittedChanges ? "uncommitted" : "base";
const diffMode = diffModeOverride ?? autoDiffMode;
const {
files,
payloadError: diffPayloadError,
@@ -485,6 +491,11 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
}
}, [isDiffFetching, isStatusFetching, isManualRefresh]);
// Clear diff mode override when auto mode changes (e.g., after commit)
useEffect(() => {
setDiffModeOverride(null);
}, [autoDiffMode]);
useEffect(() => {
if (!isPerfLoggingEnabled()) {
return;
@@ -697,7 +708,6 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const branchLabel =
gitStatus?.currentBranch ?? (notGit ? "Not a git repository" : "Unknown");
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
const canShowCommit = isGit && hasUncommittedChanges;
const canShowShip = isGit && aheadCount > 0;
@@ -780,7 +790,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
}
const hasPullRequest = Boolean(prStatus?.url);
const prActionLabel = hasPullRequest ? "Open PR" : "Create PR";
const prActionLabel = hasPullRequest ? "View PR" : "Create PR";
type ShipActionKey = "merge" | "pr";
const shipActions: { key: ShipActionKey; label: string; disabled: boolean; isPending: boolean }[] =
@@ -817,6 +827,10 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
return hasPullRequest ? false : prMutation.isPending;
}, [hasPullRequest, mergeMutation.isPending, prMutation.isPending, resolvedShipPrimary]);
// When there are uncommitted changes, Commit becomes the primary CTA
const showCommitAsPrimary = canShowCommit;
const showShipSplitButton = canShowShip && !showCommitAsPrimary;
return (
<View style={styles.container}>
<View style={styles.header} testID="changes-header">
@@ -831,7 +845,86 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
</View>
{isGit ? (
<View style={styles.headerRight}>
{canShowShip ? (
{showCommitAsPrimary ? (
<View style={styles.shipSplitButton}>
<Pressable
testID="changes-commit-primary"
style={[
styles.shipPrimaryButton,
commitDisabled && styles.shipPrimaryButtonDisabled,
]}
onPress={() => commitMutation.mutate()}
disabled={commitDisabled}
accessibilityRole="button"
accessibilityLabel="Commit changes"
>
{commitMutation.isPending ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<Text style={styles.shipPrimaryText}>Commit</Text>
)}
</Pressable>
<DropdownMenu>
<DropdownMenuTrigger
testID="changes-commit-caret"
style={styles.shipCaretButton}
accessibilityRole="button"
accessibilityLabel="More options"
>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260} testID="changes-commit-menu">
{canShowShip ? (
<>
<DropdownMenuItem
testID="changes-commit-menu-merge"
disabled={mergeDisabled}
description={mergeDisabled && hasUncommittedChanges ? "Requires clean working tree" : undefined}
onSelect={() => {
void persistShipDefault("merge");
mergeMutation.mutate();
}}
>
Merge branch
</DropdownMenuItem>
<DropdownMenuItem
testID={hasPullRequest ? "changes-commit-menu-view-pr" : "changes-commit-menu-create-pr"}
disabled={prDisabled}
onSelect={() => {
void persistShipDefault("pr");
if (hasPullRequest && prStatus?.url) {
void Linking.openURL(prStatus.url);
return;
}
prMutation.mutate();
}}
>
{prActionLabel}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : null}
<DropdownMenuItem
testID="changes-commit-menu-merge-from-base"
disabled={mergeFromBaseDisabled}
description={mergeFromBaseDisabled && hasUncommittedChanges ? "Requires clean working tree" : undefined}
onSelect={() => mergeFromBaseMutation.mutate()}
>
Merge from {baseRefLabel}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
testID="changes-commit-menu-push"
disabled={pushDisabled}
description={pushDisabled && !(gitStatus?.hasRemote ?? false) ? "No remote configured" : undefined}
onSelect={() => pushMutation.mutate()}
>
Push to remote
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</View>
) : showShipSplitButton ? (
<View style={styles.shipSplitButton}>
<Pressable
testID="changes-ship-primary"
@@ -881,7 +974,6 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
<DropdownMenuItem
testID="changes-ship-merge"
disabled={mergeDisabled}
description={mergeDisabled && hasUncommittedChanges ? "Requires clean working tree" : undefined}
onSelect={() => {
void persistShipDefault("merge");
mergeMutation.mutate();
@@ -891,7 +983,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
testID={hasPullRequest ? "changes-ship-open-pr" : "changes-ship-create-pr"}
testID={hasPullRequest ? "changes-ship-view-pr" : "changes-ship-create-pr"}
disabled={prDisabled}
onSelect={() => {
void persistShipDefault("pr");
@@ -902,7 +994,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
prMutation.mutate();
}}
>
{hasPullRequest ? "Open PR" : "Create PR"}
{prActionLabel}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -923,7 +1015,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
<>
{resolvedShipPrimary === "merge" ? (
<DropdownMenuItem
testID={hasPullRequest ? "changes-menu-open-pr" : "changes-menu-create-pr"}
testID={hasPullRequest ? "changes-menu-view-pr" : "changes-menu-create-pr"}
disabled={prDisabled}
onSelect={() => {
void persistShipDefault("pr");
@@ -934,7 +1026,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
prMutation.mutate();
}}
>
{hasPullRequest ? "Open PR" : "Create PR"}
{prActionLabel}
</DropdownMenuItem>
) : (
<DropdownMenuItem
@@ -970,6 +1062,13 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
Push to remote
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
testID="changes-menu-toggle-view"
onSelect={() => setDiffModeOverride(diffMode === "uncommitted" ? "base" : "uncommitted")}
>
{diffMode === "uncommitted" ? `Show changes vs ${baseRefLabel}` : "Show uncommitted changes"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
testID="changes-menu-archive"
destructive
@@ -985,81 +1084,15 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
</View>
{isGit ? (
<View style={styles.toolbarRow} testID="changes-toolbar">
<View style={styles.toolbarLeft}>
{canShowCommit ? (
<Pressable
testID="changes-action-commit"
style={[
styles.secondaryActionButton,
commitDisabled && styles.secondaryActionButtonDisabled,
]}
onPress={() => commitMutation.mutate()}
disabled={commitDisabled}
>
{commitMutation.isPending ? (
<ActivityIndicator size={12} color={theme.colors.foreground} style={styles.buttonSpinner} />
) : (
<Text style={styles.secondaryActionText}>Commit</Text>
)}
</Pressable>
) : (
<View style={styles.toolbarLeftSpacer} />
)}
</View>
<View style={styles.toolbarRight}>
<DropdownMenu>
<DropdownMenuTrigger
testID="changes-view-selector"
style={styles.viewSelector}
accessibilityRole="button"
accessibilityLabel="Change diff view"
>
<Text style={styles.viewSelectorText}>
{diffMode === "uncommitted" ? "Working" : "Base"}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={180} testID="changes-view-menu">
<DropdownMenuItem
testID="changes-mode-uncommitted"
selected={diffMode === "uncommitted"}
onSelect={() => setDiffMode("uncommitted")}
>
Working
</DropdownMenuItem>
<DropdownMenuItem
testID="changes-mode-base"
selected={diffMode === "base"}
onSelect={() => setDiffMode("base")}
>
Base
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</View>
<View style={styles.diffStatusRow} testID="changes-diff-status">
<Text style={styles.diffStatusText}>
{diffMode === "uncommitted" ? "Uncommitted changes" : `Changes vs ${baseRefLabel}`}
</Text>
</View>
) : null}
{actionStatus ? <Text style={styles.actionStatusText}>{actionStatus}</Text> : null}
{actionError ? <Text style={styles.actionErrorText}>{actionError}</Text> : null}
{prStatus ? (
<Pressable
style={styles.prStatusRow}
testID="changes-pr-status"
accessibilityRole="button"
accessibilityLabel="Open pull request"
onPress={() => {
if (!prStatus.url) return;
void Linking.openURL(prStatus.url);
}}
>
<Text style={styles.prStatusLabel}>PR</Text>
<Text style={styles.prStatusValue}>
{prStatus.state} {prStatus.url ? `· ${prStatus.url}` : ""}
</Text>
</Pressable>
) : null}
{prErrorMessage ? (
<Text style={styles.actionErrorText}>{prErrorMessage}</Text>
) : null}
@@ -1103,64 +1136,15 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.medium,
flexShrink: 1,
},
viewSelector: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
viewSelectorText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.medium,
},
toolbarRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
diffStatusRow: {
paddingHorizontal: theme.spacing[3],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
toolbarLeft: {
flex: 1,
minWidth: 0,
},
toolbarLeftSpacer: {
height: 36,
},
toolbarRight: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexShrink: 0,
},
secondaryActionButton: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
alignSelf: "flex-start",
},
secondaryActionButtonDisabled: {
opacity: 0.5,
},
secondaryActionText: {
diffStatusText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.medium,
},
buttonSpinner: {
height: theme.fontSize.xs * 1.4,
color: theme.colors.foregroundMuted,
},
shipSplitButton: {
flexDirection: "row",
@@ -1266,24 +1250,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
color: theme.colors.destructive,
},
prStatusRow: {
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[2],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
prStatusLabel: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
textTransform: "uppercase",
letterSpacing: 0.6,
},
prStatusValue: {
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
flexShrink: 1,
},
diffContainer: {
flex: 1,
minHeight: 0,

View File

@@ -299,7 +299,7 @@ export function GroupedAgentList({
void queryClient.prefetchQuery({
queryKey,
queryFn: async () => await client.getCheckoutStatus(agent.id),
queryFn: async () => await client.getCheckoutStatus(agent.id, { cwd: agent.cwd }),
staleTime: CHECKOUT_STATUS_STALE_TIME,
});
}

View File

@@ -194,8 +194,6 @@ type FileDownloadTokenPayload = Extract<
{ type: "file_download_token_response" }
>["payload"];
const SESSION_SNAPSHOT_STORAGE_PREFIX = "@paseo:session-snapshot:";
// Module-level map for agent initialization promises
// Key: `${serverId}:${agentId}`, Value: { promise, resolve, reject }
// This survives Fast Refresh because it's outside React component tree
@@ -210,60 +208,6 @@ function getInitKey(serverId: string, agentId: string): string {
return `${serverId}:${agentId}`;
}
type PersistedSessionSnapshot = {
agents: AgentSnapshotPayload[];
savedAt: string;
};
const getSessionSnapshotStorageKey = (serverId: string): string => {
return `${SESSION_SNAPSHOT_STORAGE_PREFIX}${serverId}`;
};
async function loadPersistedSessionSnapshot(
serverId: string
): Promise<PersistedSessionSnapshot | null> {
try {
const raw = await AsyncStorage.getItem(
getSessionSnapshotStorageKey(serverId)
);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as PersistedSessionSnapshot;
if (!Array.isArray(parsed?.agents)) {
return null;
}
return parsed;
} catch (error) {
console.error(
`[Session] Failed to load persisted snapshot for ${serverId}`,
error
);
return null;
}
}
async function persistSessionSnapshot(
serverId: string,
snapshot: { agents: AgentSnapshotPayload[] }
) {
try {
const payload: PersistedSessionSnapshot = {
agents: snapshot.agents,
savedAt: new Date().toISOString(),
};
await AsyncStorage.setItem(
getSessionSnapshotStorageKey(serverId),
JSON.stringify(payload)
);
} catch (error) {
console.error(
`[Session] Failed to persist snapshot for ${serverId}`,
error
);
}
}
function normalizeAgentSnapshot(
snapshot: AgentSnapshotPayload,
serverId: string
@@ -462,7 +406,6 @@ export function SessionProvider({
) => Promise<void>)
| null
>(null);
const hasHydratedSnapshotRef = useRef(false);
const hasRequestedInitialSnapshotRef = useRef(false);
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null
@@ -621,61 +564,6 @@ export function SessionProvider({
};
}, [serverId, updateConnectionStatus]);
useEffect(() => {
hasHydratedSnapshotRef.current = false;
setHasHydratedAgents(serverId, false);
}, [serverId, setHasHydratedAgents]);
useEffect(() => {
let isMounted = true;
const hydrateFromSnapshot = async () => {
if (hasHydratedSnapshotRef.current) {
return;
}
hasHydratedSnapshotRef.current = true;
const snapshot = await loadPersistedSessionSnapshot(serverId);
if (!snapshot || !isMounted) {
return;
}
const agents = new Map();
const pendingPermissions = new Map();
const agentLastActivity = new Map();
for (const agentSnapshot of snapshot.agents) {
const agent = normalizeAgentSnapshot(agentSnapshot, serverId);
agents.set(agent.id, agent);
agentLastActivity.set(agent.id, agent.lastActivityAt);
for (const request of agent.pendingPermissions) {
const key = derivePendingPermissionKey(agent.id, request);
pendingPermissions.set(key, { key, agentId: agent.id, request });
}
}
setAgents(serverId, (prev) => {
if (prev.size > 0) {
return prev;
}
return agents;
});
// Initialize agentLastActivity slice (top-level)
for (const [agentId, timestamp] of agentLastActivity.entries()) {
setAgentLastActivity(agentId, timestamp);
}
setPendingPermissions(serverId, pendingPermissions);
setHasHydratedAgents(serverId, true);
};
void hydrateFromSnapshot();
return () => {
isMounted = false;
};
}, [serverId, setAgents, setPendingPermissions, setHasHydratedAgents]);
const updateExplorerState = useCallback(
(agentId: string, updater: (state: any) => any) => {
setFileExplorer(serverId, (prev) => {
@@ -953,7 +841,6 @@ export function SessionProvider({
return changed ? next : prev;
});
void persistSessionSnapshot(serverId, { agents: agentsList });
setHasHydratedAgents(serverId, true);
updateConnectionStatus(serverId, {
status: "online",

View File

@@ -24,10 +24,11 @@ interface UseCheckoutStatusQueryOptions {
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
function fetchCheckoutStatus(
client: { getCheckoutStatus: (agentId: string) => Promise<CheckoutStatusPayload> },
agentId: string
client: { getCheckoutStatus: (agentId: string, options?: { cwd?: string }) => Promise<CheckoutStatusPayload> },
agentId: string,
cwd: string
): Promise<CheckoutStatusPayload> {
return client.getCheckoutStatus(agentId);
return client.getCheckoutStatus(agentId, { cwd });
}
export function useCheckoutStatusQuery({ serverId, agentId, cwd }: UseCheckoutStatusQueryOptions) {
@@ -50,7 +51,7 @@ export function useCheckoutStatusQuery({ serverId, agentId, cwd }: UseCheckoutSt
if (!client) {
throw new Error("Daemon client not available");
}
return await fetchCheckoutStatus(client, agentId);
return await fetchCheckoutStatus(client, agentId, cwd);
},
enabled: !!client && isConnected && !!agentId && !!cwd,
staleTime: CHECKOUT_STATUS_STALE_TIME,
@@ -98,7 +99,7 @@ export function useCheckoutStatusCacheOnly({ serverId, agentId, cwd }: UseChecko
if (!client) {
throw new Error("Daemon client not available");
}
return await fetchCheckoutStatus(client, agentId);
return await fetchCheckoutStatus(client, agentId, cwd);
},
enabled: false,
staleTime: CHECKOUT_STATUS_STALE_TIME,

View File

@@ -1131,8 +1131,11 @@ export class DaemonClientV2 {
async getCheckoutStatus(
agentId: string,
requestId?: string
options?: { cwd?: string; requestId?: string }
): Promise<CheckoutStatusPayload> {
const requestId = options?.requestId;
const cwd = options?.cwd;
if (!requestId) {
const existing = this.checkoutStatusInFlight.get(agentId);
if (existing) {
@@ -1144,6 +1147,7 @@ export class DaemonClientV2 {
const message = SessionInboundMessageSchema.parse({
type: "checkout_status_request",
agentId,
cwd,
requestId: resolvedRequestId,
});

View File

@@ -2855,7 +2855,9 @@ export class Session {
): Promise<void> {
const { agentId, requestId } = msg;
const agent = this.agentManager.getAgent(agentId);
if (!agent) {
// Use cwd from agent if found, otherwise fall back to cwd from message
const cwd = agent?.cwd ?? msg.cwd;
if (!cwd) {
this.emit({
type: "checkout_status_response",
payload: {
@@ -2870,7 +2872,7 @@ export class Session {
hasRemote: false,
remoteUrl: null,
isPaseoOwnedWorktree: false,
error: { code: "UNKNOWN", message: `Agent not found: ${agentId}` },
error: { code: "UNKNOWN", message: `Agent not found and no cwd provided: ${agentId}` },
requestId,
},
});
@@ -2878,13 +2880,13 @@ export class Session {
}
try {
const status = await getCheckoutStatus(agent.cwd, { paseoHome: this.paseoHome });
const status = await getCheckoutStatus(cwd, { paseoHome: this.paseoHome });
if (!status.isGit) {
this.emit({
type: "checkout_status_response",
payload: {
agentId,
cwd: agent.cwd,
cwd,
isGit: false,
repoRoot: null,
currentBranch: null,
@@ -2906,7 +2908,7 @@ export class Session {
type: "checkout_status_response",
payload: {
agentId,
cwd: agent.cwd,
cwd,
isGit: true,
repoRoot: status.repoRoot ?? null,
currentBranch: status.currentBranch ?? null,
@@ -2927,7 +2929,7 @@ export class Session {
type: "checkout_status_response",
payload: {
agentId,
cwd: agent.cwd,
cwd,
isGit: true,
repoRoot: status.repoRoot ?? null,
currentBranch: status.currentBranch ?? null,
@@ -2946,7 +2948,7 @@ export class Session {
type: "checkout_status_response",
payload: {
agentId,
cwd: agent.cwd,
cwd,
isGit: false,
repoRoot: null,
currentBranch: null,

View File

@@ -477,6 +477,8 @@ const CheckoutDiffCompareSchema = z.object({
export const CheckoutStatusRequestSchema = z.object({
type: z.literal("checkout_status_request"),
agentId: z.string(),
/** Optional cwd to use if the agent is not live in memory (e.g. from persisted agents.json) */
cwd: z.string().optional(),
requestId: z.string(),
});

View File

@@ -2,7 +2,7 @@ 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";
import { findProjectIcon, getProjectIcon, ICON_PATTERNS, PRIORITY_DIRS, IGNORED_DIRS, MONOREPO_PACKAGE_DIRS } from "./project-icon.js";
function createTempDir(): string {
return realpathSync(mkdtempSync(join(tmpdir(), "project-icon-test-")));
@@ -55,6 +55,13 @@ describe("findProjectIcon", () => {
});
});
describe("MONOREPO_PACKAGE_DIRS", () => {
it("includes common monorepo package directories", () => {
expect(MONOREPO_PACKAGE_DIRS).toContain("packages");
expect(MONOREPO_PACKAGE_DIRS).toContain("apps");
});
});
it("returns null when no icon is found", async () => {
const result = await findProjectIcon(tempDir);
expect(result).toBeNull();
@@ -175,6 +182,51 @@ describe("findProjectIcon", () => {
// Should return the first one based on pattern order (favicon.ico comes first)
expect(result).toBe(join(tempDir, "favicon.ico"));
});
describe("monorepo package directories", () => {
it("finds icon in packages/*/public directory", async () => {
mkdirSync(join(tempDir, "packages", "app", "public"), { recursive: true });
writeFileSync(join(tempDir, "packages", "app", "public", "favicon.ico"), "icon");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "packages", "app", "public", "favicon.ico"));
});
it("finds icon in apps/*/public directory", async () => {
mkdirSync(join(tempDir, "apps", "web", "public"), { recursive: true });
writeFileSync(join(tempDir, "apps", "web", "public", "favicon.png"), "icon");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "apps", "web", "public", "favicon.png"));
});
it("finds icon in packages/* root", async () => {
mkdirSync(join(tempDir, "packages", "ui"), { recursive: true });
writeFileSync(join(tempDir, "packages", "ui", "logo.svg"), "icon");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "packages", "ui", "logo.svg"));
});
it("prioritizes root priority dirs over monorepo dirs", async () => {
mkdirSync(join(tempDir, "public"), { recursive: true });
mkdirSync(join(tempDir, "packages", "app", "public"), { recursive: true });
writeFileSync(join(tempDir, "public", "favicon.ico"), "root icon");
writeFileSync(join(tempDir, "packages", "app", "public", "favicon.ico"), "package icon");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "public", "favicon.ico"));
});
it("prioritizes monorepo dirs over root dir (non-priority)", async () => {
mkdirSync(join(tempDir, "packages", "app", "public"), { recursive: true });
writeFileSync(join(tempDir, "logo.png"), "root icon");
writeFileSync(join(tempDir, "packages", "app", "public", "favicon.ico"), "package icon");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "packages", "app", "public", "favicon.ico"));
});
});
});
describe("getProjectIcon", () => {

View File

@@ -9,6 +9,9 @@ export const ICON_PATTERNS = [
"favicon.ico",
"favicon.png",
"favicon.svg",
"favico.ico",
"favico.png",
"favico.svg",
"icon.png",
"icon.svg",
"app-icon.png",
@@ -24,6 +27,11 @@ export const ICON_PATTERNS = [
*/
export const PRIORITY_DIRS = ["public", "static", "assets", "images", "img"];
/**
* Monorepo package directory patterns to scan (e.g., packages/app, apps/web).
*/
export const MONOREPO_PACKAGE_DIRS = ["packages", "apps"];
/**
* Directories to ignore during search.
*/
@@ -306,6 +314,54 @@ export async function findProjectIcon(
}
}
// Then search monorepo package directories (packages/*, apps/*)
for (const monoDir of MONOREPO_PACKAGE_DIRS) {
const monoPath = join(projectDir, monoDir);
let packageEntries: string[];
try {
packageEntries = await readdir(monoPath);
} catch {
continue;
}
for (const packageName of packageEntries) {
const packagePath = join(monoPath, packageName);
try {
const packageStats = await stat(packagePath);
if (!packageStats.isDirectory()) continue;
} catch {
continue;
}
// Search priority dirs within the package
for (const priorityDir of PRIORITY_DIRS) {
const priorityPath = join(packagePath, priorityDir);
try {
const priorityStats = await stat(priorityPath);
if (priorityStats.isDirectory()) {
const result = await searchDirRecursively(
priorityPath,
ICON_PATTERNS,
ignoredDirsSet,
maxDepth - 1
);
if (result) {
return result;
}
}
} catch {
// Directory doesn't exist, continue
}
}
// Search package root
const found = await findIconInDir(packagePath, ICON_PATTERNS);
if (found) {
return found;
}
}
}
// Then search root and any other non-priority directories
const found = await findDirRecursively(projectDir);
if (found) {