fix: keep recent projects visible during refresh
This commit is contained in:
parent
8a7c1a764b
commit
58c21f3d24
3 changed files with 192 additions and 17 deletions
|
|
@ -7,6 +7,10 @@ import { buildTaskCountsByProject, normalizePath } from '@renderer/utils/pathNor
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter';
|
import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter';
|
||||||
|
import {
|
||||||
|
getRecentProjectsClientSnapshot,
|
||||||
|
loadRecentProjectsWithClientCache,
|
||||||
|
} from '../utils/recentProjectsClientCache';
|
||||||
|
|
||||||
import { useOpenRecentProject } from './useOpenRecentProject';
|
import { useOpenRecentProject } from './useOpenRecentProject';
|
||||||
|
|
||||||
|
|
@ -51,27 +55,45 @@ export function useRecentProjectsSection(
|
||||||
openProjectPath: (projectPath: string) => Promise<void>;
|
openProjectPath: (projectPath: string) => Promise<void>;
|
||||||
selectProjectFolder: () => Promise<void>;
|
selectProjectFolder: () => Promise<void>;
|
||||||
} {
|
} {
|
||||||
const { globalTasks, globalTasksLoading, fetchAllTasks, teams } = useStore(
|
const { globalTasks, globalTasksInitialized, globalTasksLoading, fetchAllTasks, teams } =
|
||||||
useShallow((state) => ({
|
useStore(
|
||||||
globalTasks: state.globalTasks,
|
useShallow((state) => ({
|
||||||
globalTasksLoading: state.globalTasksLoading,
|
globalTasks: state.globalTasks,
|
||||||
fetchAllTasks: state.fetchAllTasks,
|
globalTasksInitialized: state.globalTasksInitialized,
|
||||||
teams: state.teams,
|
globalTasksLoading: state.globalTasksLoading,
|
||||||
}))
|
fetchAllTasks: state.fetchAllTasks,
|
||||||
);
|
teams: state.teams,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []);
|
||||||
const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject();
|
const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject();
|
||||||
const [recentProjects, setRecentProjects] = useState<DashboardRecentProject[]>([]);
|
const [recentProjects, setRecentProjects] = useState<DashboardRecentProject[]>(
|
||||||
const [loading, setLoading] = useState(true);
|
initialSnapshot?.projects ?? []
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(initialSnapshot == null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [visibleProjects, setVisibleProjects] = useState(maxProjects);
|
const [visibleProjects, setVisibleProjects] = useState(maxProjects);
|
||||||
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
|
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
|
||||||
const hasFetchedTasksRef = useRef(false);
|
const hasFetchedTasksRef = useRef(globalTasksInitialized);
|
||||||
|
const recentProjectsRef = useRef<DashboardRecentProject[]>(initialSnapshot?.projects ?? []);
|
||||||
|
|
||||||
const reload = useCallback(async (): Promise<void> => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
recentProjectsRef.current = recentProjects;
|
||||||
|
}, [recentProjects]);
|
||||||
|
|
||||||
|
const reload = useCallback(async (options?: { force?: boolean }): Promise<void> => {
|
||||||
|
const hasVisibleProjects =
|
||||||
|
recentProjectsRef.current.length > 0 || getRecentProjectsClientSnapshot() != null;
|
||||||
|
|
||||||
|
if (!hasVisibleProjects) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const projects = await api.getDashboardRecentProjects();
|
const projects = await loadRecentProjectsWithClientCache(
|
||||||
|
() => api.getDashboardRecentProjects(),
|
||||||
|
options
|
||||||
|
);
|
||||||
setRecentProjects(projects);
|
setRecentProjects(projects);
|
||||||
} catch (nextError) {
|
} catch (nextError) {
|
||||||
setError(nextError instanceof Error ? nextError.message : 'Failed to load recent projects');
|
setError(nextError instanceof Error ? nextError.message : 'Failed to load recent projects');
|
||||||
|
|
@ -81,17 +103,23 @@ export function useRecentProjectsSection(
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void reload();
|
const snapshot = getRecentProjectsClientSnapshot();
|
||||||
|
if (snapshot && !snapshot.isStale) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void reload({ force: snapshot != null });
|
||||||
}, [reload]);
|
}, [reload]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (recentProjects.length === 0 || hasFetchedTasksRef.current) {
|
if (recentProjects.length === 0 || hasFetchedTasksRef.current || globalTasksInitialized) {
|
||||||
|
hasFetchedTasksRef.current = hasFetchedTasksRef.current || globalTasksInitialized;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
hasFetchedTasksRef.current = true;
|
hasFetchedTasksRef.current = true;
|
||||||
void fetchAllTasks();
|
void fetchAllTasks();
|
||||||
}, [fetchAllTasks, recentProjects.length]);
|
}, [fetchAllTasks, globalTasksInitialized, recentProjects.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import type { DashboardRecentProject } from '@features/recent-projects/contracts';
|
||||||
|
|
||||||
|
const RECENT_PROJECTS_CLIENT_CACHE_TTL_MS = 15_000;
|
||||||
|
|
||||||
|
let cachedProjects: DashboardRecentProject[] | null = null;
|
||||||
|
let cachedAt = 0;
|
||||||
|
let inFlightLoad: Promise<DashboardRecentProject[]> | null = null;
|
||||||
|
|
||||||
|
export interface RecentProjectsClientSnapshot {
|
||||||
|
projects: DashboardRecentProject[];
|
||||||
|
fetchedAt: number;
|
||||||
|
isStale: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecentProjectsClientSnapshot(): RecentProjectsClientSnapshot | null {
|
||||||
|
if (!cachedProjects) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects: cachedProjects,
|
||||||
|
fetchedAt: cachedAt,
|
||||||
|
isStale: Date.now() - cachedAt > RECENT_PROJECTS_CLIENT_CACHE_TTL_MS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadRecentProjectsWithClientCache(
|
||||||
|
loader: () => Promise<DashboardRecentProject[]>,
|
||||||
|
options?: { force?: boolean }
|
||||||
|
): Promise<DashboardRecentProject[]> {
|
||||||
|
const force = options?.force ?? false;
|
||||||
|
const snapshot = getRecentProjectsClientSnapshot();
|
||||||
|
|
||||||
|
if (!force && snapshot && !snapshot.isStale) {
|
||||||
|
return snapshot.projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inFlightLoad) {
|
||||||
|
return inFlightLoad;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = loader()
|
||||||
|
.then((projects) => {
|
||||||
|
cachedProjects = projects;
|
||||||
|
cachedAt = Date.now();
|
||||||
|
return projects;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (inFlightLoad === request) {
|
||||||
|
inFlightLoad = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
inFlightLoad = request;
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function __resetRecentProjectsClientCacheForTests(): void {
|
||||||
|
cachedProjects = null;
|
||||||
|
cachedAt = 0;
|
||||||
|
inFlightLoad = null;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
__resetRecentProjectsClientCacheForTests,
|
||||||
|
getRecentProjectsClientSnapshot,
|
||||||
|
loadRecentProjectsWithClientCache,
|
||||||
|
} from '@features/recent-projects/renderer/utils/recentProjectsClientCache';
|
||||||
|
|
||||||
|
import type { DashboardRecentProject } from '@features/recent-projects/contracts';
|
||||||
|
|
||||||
|
const project = (id: string): DashboardRecentProject => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
primaryPath: `/tmp/${id}`,
|
||||||
|
associatedPaths: [`/tmp/${id}`],
|
||||||
|
primaryBranch: null,
|
||||||
|
providerIds: ['anthropic'],
|
||||||
|
updatedAt: '2026-04-14T12:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recentProjectsClientCache', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
__resetRecentProjectsClientCacheForTests();
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns cached projects while the client cache is fresh', async () => {
|
||||||
|
const loader = vi.fn().mockResolvedValue([project('alpha')]);
|
||||||
|
|
||||||
|
await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual([project('alpha')]);
|
||||||
|
await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual([project('alpha')]);
|
||||||
|
|
||||||
|
expect(loader).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getRecentProjectsClientSnapshot()?.projects).toEqual([project('alpha')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revalidates stale cache without dropping the previous snapshot', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-04-14T12:00:00.000Z'));
|
||||||
|
|
||||||
|
const loader = vi
|
||||||
|
.fn<() => Promise<DashboardRecentProject[]>>()
|
||||||
|
.mockResolvedValueOnce([project('alpha')])
|
||||||
|
.mockResolvedValueOnce([project('beta')]);
|
||||||
|
|
||||||
|
await loadRecentProjectsWithClientCache(loader);
|
||||||
|
vi.setSystemTime(new Date('2026-04-14T12:00:16.000Z'));
|
||||||
|
|
||||||
|
expect(getRecentProjectsClientSnapshot()).toMatchObject({
|
||||||
|
projects: [project('alpha')],
|
||||||
|
isStale: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(loadRecentProjectsWithClientCache(loader, { force: true })).resolves.toEqual([
|
||||||
|
project('beta'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(loader).toHaveBeenCalledTimes(2);
|
||||||
|
expect(getRecentProjectsClientSnapshot()).toMatchObject({
|
||||||
|
projects: [project('beta')],
|
||||||
|
isStale: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates concurrent client refreshes', async () => {
|
||||||
|
let resolveLoader: ((projects: DashboardRecentProject[]) => void) | null = null;
|
||||||
|
const loader = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<DashboardRecentProject[]>((resolve) => {
|
||||||
|
resolveLoader = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const first = loadRecentProjectsWithClientCache(loader, { force: true });
|
||||||
|
const second = loadRecentProjectsWithClientCache(loader, { force: true });
|
||||||
|
|
||||||
|
expect(loader).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
resolveLoader?.([project('alpha')]);
|
||||||
|
|
||||||
|
await expect(first).resolves.toEqual([project('alpha')]);
|
||||||
|
await expect(second).resolves.toEqual([project('alpha')]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue