diff --git a/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts index 15c4295a..7abf05db 100644 --- a/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts +++ b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts @@ -29,6 +29,7 @@ export interface ListDashboardRecentProjectsDeps { export class ListDashboardRecentProjectsUseCase { readonly #cacheTtlMs: number; readonly #degradedCacheTtlMs: number; + readonly #inFlightByCacheKey = new Map>(); constructor(private readonly deps: ListDashboardRecentProjectsDeps) { this.#cacheTtlMs = deps.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; @@ -41,6 +42,20 @@ export class ListDashboardRecentProjectsUseCase { return cached; } + const inFlight = this.#inFlightByCacheKey.get(cacheKey); + if (inFlight) { + return inFlight; + } + + const execution = this.#executeUncached(cacheKey).finally(() => { + this.#inFlightByCacheKey.delete(cacheKey); + }); + + this.#inFlightByCacheKey.set(cacheKey, execution); + return execution; + } + + async #executeUncached(cacheKey: string): Promise { const startedAt = this.deps.clock.now(); const results = await Promise.all( this.deps.sources.map((source, index) => this.#loadSource(source, index)) diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts index 4430b8bb..b98ebc7b 100644 --- a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts @@ -12,10 +12,10 @@ import type { RecentProjectIdentityResolver } from '@features/recent-projects/ma import type { ServiceContext } from '@main/services'; const CODEX_THREAD_LIMIT = 40; -const CODEX_LIVE_FETCH_TIMEOUT_MS = 1_200; -const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 1_800; -const CODEX_REQUEST_TIMEOUT_MS = 1_800; -const CODEX_SOURCE_TIMEOUT_MS = 1_500; +const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500; +const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500; +const CODEX_REQUEST_TIMEOUT_MS = 4_500; +const CODEX_SOURCE_TIMEOUT_MS = 5_200; const FAST_ARCHIVED_MERGE_TIMEOUT_MS = 150; function isInteractiveSource(source: unknown): boolean { diff --git a/src/features/recent-projects/main/infrastructure/codex/CodexBinaryResolver.ts b/src/features/recent-projects/main/infrastructure/codex/CodexBinaryResolver.ts index 7239af90..7b7184bf 100644 --- a/src/features/recent-projects/main/infrastructure/codex/CodexBinaryResolver.ts +++ b/src/features/recent-projects/main/infrastructure/codex/CodexBinaryResolver.ts @@ -1,4 +1,6 @@ -import { execCli } from '@main/utils/childProcess'; +import { constants as fsConstants } from 'node:fs'; +import * as fsp from 'node:fs/promises'; +import path from 'node:path'; const CACHE_VERIFY_TTL_MS = 30_000; @@ -6,13 +8,60 @@ let cachedBinaryPath: string | null | undefined; let cacheVerifiedAt = 0; let resolveInFlight: Promise | null = null; -async function verifyBinary(candidate: string): Promise { +async function fileExists(filePath: string): Promise { try { - await execCli(candidate, ['--version'], { timeout: 2_000, windowsHide: true }); - return candidate; + await fsp.access(filePath, fsConstants.X_OK); + return true; } catch { + return false; + } +} + +function expandWindowsExtensions(candidate: string): string[] { + if (process.platform !== 'win32') { + return [candidate]; + } + + const pathext = process.env.PATHEXT?.split(';').filter(Boolean) ?? [ + '.EXE', + '.CMD', + '.BAT', + '.COM', + ]; + const hasKnownExtension = pathext.some((ext) => + candidate.toLowerCase().endsWith(ext.toLowerCase()) + ); + + if (hasKnownExtension) { + return [candidate]; + } + + return [candidate, ...pathext.map((ext) => `${candidate}${ext.toLowerCase()}`)]; +} + +async function verifyBinary(candidate: string): Promise { + const expandedCandidates = expandWindowsExtensions(candidate); + + if (path.isAbsolute(candidate) || candidate.includes(path.sep)) { + for (const expandedCandidate of expandedCandidates) { + if (await fileExists(expandedCandidate)) { + return expandedCandidate; + } + } return null; } + + const pathEntries = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean); + for (const pathEntry of pathEntries) { + for (const expandedCandidate of expandedCandidates) { + const resolvedCandidate = path.join(pathEntry, expandedCandidate); + if (await fileExists(resolvedCandidate)) { + return resolvedCandidate; + } + } + } + + return null; } export class CodexBinaryResolver { diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index 22f8d572..f55b1f24 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -325,7 +325,28 @@ export const DateGroupedSessions = (): React.JSX.Element => { }); }, [viewMode, repositoryGroups, projects]); - const activeProjectValue = viewMode === 'grouped' ? selectedRepositoryId : activeProjectId; + const effectiveSelectedWorktreeId = + selectedWorktreeId ?? activeProjectId ?? selectedProjectId ?? null; + const effectiveSelectedRepositoryId = useMemo(() => { + if (selectedRepositoryId) { + return selectedRepositoryId; + } + + if (!effectiveSelectedWorktreeId) { + return null; + } + + return ( + repositoryGroups.find((repo) => + repo.worktrees.some((worktree) => worktree.id === effectiveSelectedWorktreeId) + )?.id ?? null + ); + }, [effectiveSelectedWorktreeId, repositoryGroups, selectedRepositoryId]); + + const activeProjectValue = + viewMode === 'grouped' + ? effectiveSelectedRepositoryId + : (activeProjectId ?? selectedProjectId ?? null); const handleProjectValueChange = (id: string): void => { if (viewMode === 'grouped') selectRepository(id); @@ -333,8 +354,8 @@ export const DateGroupedSessions = (): React.JSX.Element => { }; // Worktree state - const activeRepo = repositoryGroups.find((r) => r.id === selectedRepositoryId); - const activeWorktree = activeRepo?.worktrees.find((w) => w.id === selectedWorktreeId); + const activeRepo = repositoryGroups.find((r) => r.id === effectiveSelectedRepositoryId); + const activeWorktree = activeRepo?.worktrees.find((w) => w.id === effectiveSelectedWorktreeId); const worktrees = (activeRepo?.worktrees ?? []).filter( (w) => (w.totalSessions ?? w.sessions.length) > 0 ); @@ -684,7 +705,7 @@ export const DateGroupedSessions = (): React.JSX.Element => { {mainWorktree && ( handleSelectWorktree(mainWorktree)} /> )} @@ -705,7 +726,7 @@ export const DateGroupedSessions = (): React.JSX.Element => { handleSelectWorktree(worktree)} /> ))}