fix: stabilize codex recent project selection
This commit is contained in:
parent
c8f9d9bbdd
commit
f0f43be064
4 changed files with 98 additions and 13 deletions
|
|
@ -29,6 +29,7 @@ export interface ListDashboardRecentProjectsDeps<TViewModel> {
|
||||||
export class ListDashboardRecentProjectsUseCase<TViewModel> {
|
export class ListDashboardRecentProjectsUseCase<TViewModel> {
|
||||||
readonly #cacheTtlMs: number;
|
readonly #cacheTtlMs: number;
|
||||||
readonly #degradedCacheTtlMs: number;
|
readonly #degradedCacheTtlMs: number;
|
||||||
|
readonly #inFlightByCacheKey = new Map<string, Promise<TViewModel>>();
|
||||||
|
|
||||||
constructor(private readonly deps: ListDashboardRecentProjectsDeps<TViewModel>) {
|
constructor(private readonly deps: ListDashboardRecentProjectsDeps<TViewModel>) {
|
||||||
this.#cacheTtlMs = deps.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
this.#cacheTtlMs = deps.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
||||||
|
|
@ -41,6 +42,20 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
|
||||||
return cached;
|
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<TViewModel> {
|
||||||
const startedAt = this.deps.clock.now();
|
const startedAt = this.deps.clock.now();
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
this.deps.sources.map((source, index) => this.#loadSource(source, index))
|
this.deps.sources.map((source, index) => this.#loadSource(source, index))
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,10 @@ import type { RecentProjectIdentityResolver } from '@features/recent-projects/ma
|
||||||
import type { ServiceContext } from '@main/services';
|
import type { ServiceContext } from '@main/services';
|
||||||
|
|
||||||
const CODEX_THREAD_LIMIT = 40;
|
const CODEX_THREAD_LIMIT = 40;
|
||||||
const CODEX_LIVE_FETCH_TIMEOUT_MS = 1_200;
|
const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500;
|
||||||
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 1_800;
|
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500;
|
||||||
const CODEX_REQUEST_TIMEOUT_MS = 1_800;
|
const CODEX_REQUEST_TIMEOUT_MS = 4_500;
|
||||||
const CODEX_SOURCE_TIMEOUT_MS = 1_500;
|
const CODEX_SOURCE_TIMEOUT_MS = 5_200;
|
||||||
const FAST_ARCHIVED_MERGE_TIMEOUT_MS = 150;
|
const FAST_ARCHIVED_MERGE_TIMEOUT_MS = 150;
|
||||||
|
|
||||||
function isInteractiveSource(source: unknown): boolean {
|
function isInteractiveSource(source: unknown): boolean {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
const CACHE_VERIFY_TTL_MS = 30_000;
|
||||||
|
|
||||||
|
|
@ -6,13 +8,60 @@ let cachedBinaryPath: string | null | undefined;
|
||||||
let cacheVerifiedAt = 0;
|
let cacheVerifiedAt = 0;
|
||||||
let resolveInFlight: Promise<string | null> | null = null;
|
let resolveInFlight: Promise<string | null> | null = null;
|
||||||
|
|
||||||
async function verifyBinary(candidate: string): Promise<string | null> {
|
async function fileExists(filePath: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await execCli(candidate, ['--version'], { timeout: 2_000, windowsHide: true });
|
await fsp.access(filePath, fsConstants.X_OK);
|
||||||
return candidate;
|
return true;
|
||||||
} catch {
|
} 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<string | null> {
|
||||||
|
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;
|
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 {
|
export class CodexBinaryResolver {
|
||||||
|
|
|
||||||
|
|
@ -325,7 +325,28 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
||||||
});
|
});
|
||||||
}, [viewMode, repositoryGroups, projects]);
|
}, [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 => {
|
const handleProjectValueChange = (id: string): void => {
|
||||||
if (viewMode === 'grouped') selectRepository(id);
|
if (viewMode === 'grouped') selectRepository(id);
|
||||||
|
|
@ -333,8 +354,8 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Worktree state
|
// Worktree state
|
||||||
const activeRepo = repositoryGroups.find((r) => r.id === selectedRepositoryId);
|
const activeRepo = repositoryGroups.find((r) => r.id === effectiveSelectedRepositoryId);
|
||||||
const activeWorktree = activeRepo?.worktrees.find((w) => w.id === selectedWorktreeId);
|
const activeWorktree = activeRepo?.worktrees.find((w) => w.id === effectiveSelectedWorktreeId);
|
||||||
const worktrees = (activeRepo?.worktrees ?? []).filter(
|
const worktrees = (activeRepo?.worktrees ?? []).filter(
|
||||||
(w) => (w.totalSessions ?? w.sessions.length) > 0
|
(w) => (w.totalSessions ?? w.sessions.length) > 0
|
||||||
);
|
);
|
||||||
|
|
@ -684,7 +705,7 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
||||||
{mainWorktree && (
|
{mainWorktree && (
|
||||||
<WorktreeItem
|
<WorktreeItem
|
||||||
worktree={mainWorktree}
|
worktree={mainWorktree}
|
||||||
isSelected={mainWorktree.id === selectedWorktreeId}
|
isSelected={mainWorktree.id === effectiveSelectedWorktreeId}
|
||||||
onSelect={() => handleSelectWorktree(mainWorktree)}
|
onSelect={() => handleSelectWorktree(mainWorktree)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -705,7 +726,7 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
||||||
<WorktreeItem
|
<WorktreeItem
|
||||||
key={worktree.id}
|
key={worktree.id}
|
||||||
worktree={worktree}
|
worktree={worktree}
|
||||||
isSelected={worktree.id === selectedWorktreeId}
|
isSelected={worktree.id === effectiveSelectedWorktreeId}
|
||||||
onSelect={() => handleSelectWorktree(worktree)}
|
onSelect={() => handleSelectWorktree(worktree)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue