270 lines
8.4 KiB
TypeScript
270 lines
8.4 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import { type DashboardRecentProject } from '@features/recent-projects/contracts';
|
|
import { api, isElectronMode } from '@renderer/api';
|
|
import { useStore } from '@renderer/store';
|
|
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
|
import { buildTaskCountsByProject } from '@renderer/utils/pathNormalize';
|
|
import { useShallow } from 'zustand/react/shallow';
|
|
|
|
import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter';
|
|
import { buildActiveTeamsByProject } from '../utils/activeProjectTeams';
|
|
import {
|
|
sortRecentProjectsByDisplayPriority,
|
|
subscribeRecentProjectOpenHistory,
|
|
} from '../utils/recentProjectOpenHistory';
|
|
import {
|
|
getRecentProjectsClientSnapshot,
|
|
loadRecentProjectsWithClientCache,
|
|
} from '../utils/recentProjectsClientCache';
|
|
|
|
import { useOpenRecentProject } from './useOpenRecentProject';
|
|
|
|
import type { RecentProjectCardModel } from '../adapters/RecentProjectsSectionAdapter';
|
|
import type { TeamSummary } from '@shared/types';
|
|
|
|
const INITIAL_RECENT_PROJECTS = 11;
|
|
const LOAD_MORE_STEP = 8;
|
|
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS = 1_500;
|
|
const DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS = 5_000;
|
|
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_LIMIT = 3;
|
|
|
|
function matchesSearch(project: DashboardRecentProject, query: string): boolean {
|
|
if (!query) {
|
|
return true;
|
|
}
|
|
|
|
const normalizedQuery = query.trim().toLowerCase();
|
|
if (!normalizedQuery) {
|
|
return true;
|
|
}
|
|
|
|
return (
|
|
project.name.toLowerCase().includes(normalizedQuery) ||
|
|
project.primaryPath.toLowerCase().includes(normalizedQuery) ||
|
|
project.associatedPaths.some((projectPath) =>
|
|
projectPath.toLowerCase().includes(normalizedQuery)
|
|
) ||
|
|
project.primaryBranch?.toLowerCase().includes(normalizedQuery) === true
|
|
);
|
|
}
|
|
|
|
export function useRecentProjectsSection(
|
|
searchQuery: string,
|
|
maxProjects = INITIAL_RECENT_PROJECTS
|
|
): {
|
|
cards: RecentProjectCardModel[];
|
|
loading: boolean;
|
|
error: string | null;
|
|
canLoadMore: boolean;
|
|
isElectron: boolean;
|
|
loadMore: () => void;
|
|
reload: () => Promise<void>;
|
|
openRecentProject: (project: DashboardRecentProject) => Promise<void>;
|
|
openProjectPath: (projectPath: string) => Promise<void>;
|
|
selectProjectFolder: () => Promise<void>;
|
|
} {
|
|
const {
|
|
globalTasks,
|
|
globalTasksInitialized,
|
|
globalTasksLoading,
|
|
fetchAllTasks,
|
|
teams,
|
|
provisioningRuns,
|
|
currentProvisioningRunIdByTeam,
|
|
provisioningSnapshotByTeam,
|
|
} = useStore(
|
|
useShallow((state) => ({
|
|
globalTasks: state.globalTasks,
|
|
globalTasksInitialized: state.globalTasksInitialized,
|
|
globalTasksLoading: state.globalTasksLoading,
|
|
fetchAllTasks: state.fetchAllTasks,
|
|
teams: state.teams,
|
|
provisioningRuns: state.provisioningRuns,
|
|
currentProvisioningRunIdByTeam: state.currentProvisioningRunIdByTeam,
|
|
provisioningSnapshotByTeam: state.provisioningSnapshotByTeam,
|
|
}))
|
|
);
|
|
const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []);
|
|
const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject();
|
|
const [recentProjects, setRecentProjects] = useState<DashboardRecentProject[]>(
|
|
initialSnapshot?.payload.projects ?? []
|
|
);
|
|
const [recentProjectsDegraded, setRecentProjectsDegraded] = useState(
|
|
initialSnapshot?.payload.degraded ?? false
|
|
);
|
|
const [degradedRefreshCount, setDegradedRefreshCount] = useState(
|
|
initialSnapshot?.payload.degraded ? 1 : 0
|
|
);
|
|
const [loading, setLoading] = useState(initialSnapshot == null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [visibleProjects, setVisibleProjects] = useState(maxProjects);
|
|
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
|
|
const [openHistoryVersion, setOpenHistoryVersion] = useState(0);
|
|
const hasFetchedTasksRef = useRef(globalTasksInitialized);
|
|
const recentProjectsRef = useRef<DashboardRecentProject[]>(
|
|
initialSnapshot?.payload.projects ?? []
|
|
);
|
|
const provisioningState = useMemo(
|
|
() => ({ currentProvisioningRunIdByTeam, provisioningRuns }),
|
|
[currentProvisioningRunIdByTeam, provisioningRuns]
|
|
);
|
|
const provisioningTeamNames = useMemo(
|
|
() =>
|
|
Object.keys(currentProvisioningRunIdByTeam).filter((teamName) =>
|
|
isTeamProvisioningActive(provisioningState, teamName)
|
|
),
|
|
[currentProvisioningRunIdByTeam, provisioningState]
|
|
);
|
|
const provisioningTeamNamesKey = useMemo(
|
|
() => [...provisioningTeamNames].sort().join('\u0000'),
|
|
[provisioningTeamNames]
|
|
);
|
|
|
|
useEffect(() => {
|
|
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);
|
|
try {
|
|
const payload = await loadRecentProjectsWithClientCache(
|
|
() => api.getDashboardRecentProjects(),
|
|
options
|
|
);
|
|
setRecentProjects(payload.projects);
|
|
setRecentProjectsDegraded(payload.degraded);
|
|
setDegradedRefreshCount((current) => (payload.degraded ? current + 1 : 0));
|
|
} catch (nextError) {
|
|
setError(nextError instanceof Error ? nextError.message : 'Failed to load recent projects');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const snapshot = getRecentProjectsClientSnapshot();
|
|
if (snapshot && !snapshot.isStale) {
|
|
return;
|
|
}
|
|
|
|
void reload({ force: snapshot != null });
|
|
}, [reload]);
|
|
|
|
useEffect(() => {
|
|
if (!recentProjectsDegraded) {
|
|
return;
|
|
}
|
|
|
|
const delayMs =
|
|
degradedRefreshCount <= DEGRADED_RECENT_PROJECTS_FAST_RETRY_LIMIT
|
|
? DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS
|
|
: DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS;
|
|
|
|
const timer = window.setTimeout(() => {
|
|
void reload({ force: true });
|
|
}, delayMs);
|
|
|
|
return () => {
|
|
window.clearTimeout(timer);
|
|
};
|
|
}, [degradedRefreshCount, recentProjectsDegraded, reload]);
|
|
|
|
useEffect(() => {
|
|
if (recentProjects.length === 0 || hasFetchedTasksRef.current || globalTasksInitialized) {
|
|
hasFetchedTasksRef.current = hasFetchedTasksRef.current || globalTasksInitialized;
|
|
return;
|
|
}
|
|
|
|
hasFetchedTasksRef.current = true;
|
|
void fetchAllTasks();
|
|
}, [fetchAllTasks, globalTasksInitialized, recentProjects.length]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
void api.teams
|
|
.aliveList()
|
|
.then((teamNames) => {
|
|
if (!cancelled) {
|
|
setAliveTeams(teamNames);
|
|
}
|
|
})
|
|
.catch(() => undefined);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [provisioningTeamNamesKey, teams]);
|
|
|
|
useEffect(() => {
|
|
if (!searchQuery.trim()) {
|
|
setVisibleProjects(maxProjects);
|
|
}
|
|
}, [maxProjects, searchQuery]);
|
|
|
|
useEffect(
|
|
() => subscribeRecentProjectOpenHistory(() => setOpenHistoryVersion((current) => current + 1)),
|
|
[]
|
|
);
|
|
|
|
const taskCountsByProject = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]);
|
|
|
|
const activeTeamsByProject = useMemo(() => {
|
|
return buildActiveTeamsByProject({
|
|
teams,
|
|
aliveTeamNames: aliveTeams,
|
|
provisioningTeamNames,
|
|
provisioningSnapshotByTeam,
|
|
});
|
|
}, [aliveTeams, provisioningSnapshotByTeam, provisioningTeamNames, teams]);
|
|
|
|
const decoratedCards = useMemo(
|
|
() =>
|
|
adaptRecentProjectsSection({
|
|
projects: sortRecentProjectsByDisplayPriority(recentProjects),
|
|
taskCountsByProject,
|
|
activeTeamsByProject,
|
|
tasksLoading: globalTasksLoading,
|
|
}),
|
|
[
|
|
activeTeamsByProject,
|
|
globalTasksLoading,
|
|
openHistoryVersion,
|
|
recentProjects,
|
|
taskCountsByProject,
|
|
]
|
|
);
|
|
|
|
const filteredCards = useMemo(
|
|
() => decoratedCards.filter((card) => matchesSearch(card.project, searchQuery)),
|
|
[decoratedCards, searchQuery]
|
|
);
|
|
|
|
const cards = useMemo(() => {
|
|
if (searchQuery.trim()) {
|
|
return filteredCards;
|
|
}
|
|
|
|
return filteredCards.slice(0, visibleProjects);
|
|
}, [filteredCards, searchQuery, visibleProjects]);
|
|
|
|
return {
|
|
cards,
|
|
loading,
|
|
error,
|
|
canLoadMore: !searchQuery.trim() && filteredCards.length > visibleProjects,
|
|
isElectron: isElectronMode(),
|
|
loadMore: () => setVisibleProjects((current) => current + LOAD_MORE_STEP),
|
|
reload,
|
|
openRecentProject,
|
|
openProjectPath,
|
|
selectProjectFolder,
|
|
};
|
|
}
|