/** * Repo loader — orchestrates cache-then-refresh loading of local projects * and GitHub repos. Owns the temporal state machine (in-flight guard, * 400ms debounce on the refreshing flag) that the React hook used to * embed. Pure TypeScript; imports nothing from react. * * See ADR-0006. */ import { setLoading, setError, setMessage, startAction, endAction, updateProgress, } from "./actions.ts"; import { sortProjects, scanWithCache as defaultScanWithCache, loadFreshProjectsFromCache as defaultLoadFreshProjectsFromCache } from "../scanner/index.ts"; import { cloneGitHubRepo as defaultCloneGitHubRepo, createUnifiedView, } from "../github/unified.ts"; import { fetchGitHubReposWithCache as defaultFetchGitHubReposWithCache, loadGitHubReposFromCache as defaultLoadGitHubReposFromCache, } from "../github/cache.ts"; import { errorToString } from "../utils/errors.ts"; import type { GitforestConfig, UnifiedRepo, Project, GitHubRepoInfo, UnifiedAppAction, UnifiedAppState, } from "../types/index.ts"; const REFRESHING_FLAG_DEBOUNCE_MS = 400; export interface RepoLoaderDeps { config: GitforestConfig; dispatch: (action: UnifiedAppAction) => void; getState: () => UnifiedAppState; // Injectable IO surface (defaults wire to the real implementations). scanWithCache?: typeof defaultScanWithCache; loadFreshProjectsFromCache?: typeof defaultLoadFreshProjectsFromCache; fetchGitHubReposWithCache?: typeof defaultFetchGitHubReposWithCache; loadGitHubReposFromCache?: typeof defaultLoadGitHubReposFromCache; cloneGitHubRepo?: typeof defaultCloneGitHubRepo; } export interface RepoLoader { load(options?: { forceRefresh?: boolean }): Promise; refreshGitHub(): Promise; backgroundRefresh(): Promise; cloneRepo( repo: UnifiedRepo, targetDir: string, useSSH?: boolean, ): Promise<{ success: boolean; path?: string; error?: string }>; batchClone( repos: UnifiedRepo[], targetDir: string, useSSH?: boolean, ): Promise<{ successful: number; failed: number }>; } export function createRepoLoader(deps: RepoLoaderDeps): RepoLoader { const { config, dispatch, getState, scanWithCache = defaultScanWithCache, loadFreshProjectsFromCache = defaultLoadFreshProjectsFromCache, fetchGitHubReposWithCache = defaultFetchGitHubReposWithCache, loadGitHubReposFromCache = defaultLoadGitHubReposFromCache, cloneGitHubRepo = defaultCloneGitHubRepo, } = deps; let isRefreshing = false; let loadInFlight: Promise | null = null; function getSort() { const { sortBy, sortDirection } = getState(); return { sortBy, sortDirection }; } /** * Publish local results independently of the GitHub request. The list can * render from this snapshot while the GitHub request is still in flight. */ function dispatchLocalResults( localProjects: Project[], githubRepos: GitHubRepoInfo[], finishInitialLoading = false, ): UnifiedRepo[] { const { sortBy, sortDirection } = getSort(); const sorted = sortProjects(localProjects, sortBy, sortDirection); const unified = createUnifiedView(localProjects, githubRepos); dispatch({ type: "SET_REPO_DATA", payload: { projects: sorted, githubRepos, unifiedRepos: unified, finishInitialLoading, }, }); return unified; } async function loadFromCacheInstantly(): Promise<{ localProjects: Project[]; githubRepos: GitHubRepoInfo[]; hasCachedData: boolean; hasFreshLocalCache: boolean; }> { try { const [cachedLocalProjects, githubRepos] = await Promise.all([ loadFreshProjectsFromCache(config), loadGitHubReposFromCache(), ]); const localProjects = cachedLocalProjects ?? []; const hasFreshLocalCache = cachedLocalProjects !== null; const hasCachedData = hasFreshLocalCache || githubRepos.length > 0; if (hasCachedData) { dispatchLocalResults(localProjects, githubRepos, true); } return { localProjects, githubRepos, hasCachedData, hasFreshLocalCache }; } catch (error) { console.error("Failed to load from cache:", error); return { localProjects: [], githubRepos: [], hasCachedData: false, hasFreshLocalCache: false }; } } async function refreshInBackground(): Promise { if (isRefreshing) return; isRefreshing = true; // Only raise refreshing flag if the run lasts beyond REFRESHING_FLAG_DEBOUNCE_MS const refreshTimer: ReturnType = setTimeout(() => { dispatch({ type: "SET_REFRESHING", payload: true }); }, REFRESHING_FLAG_DEBOUNCE_MS); try { const existingGithubRepos = getState().githubRepos; const localPromise = scanWithCache(config, { forceRefresh: true }); const githubPromise = fetchGitHubReposWithCache({ includeArchived: false, includeForks: true, includeOrgs: true, }, 0); const resultsPromise = Promise.allSettled([localPromise, githubPromise]); const freshLocalProjects = await localPromise; // Keep local refreshes visible even when GitHub is slow. dispatchLocalResults(freshLocalProjects, existingGithubRepos); const [, githubOutcome] = await resultsPromise; if (githubOutcome.status === "rejected") { throw githubOutcome.reason; } const githubResult = githubOutcome.value; const freshUnified = dispatchLocalResults(freshLocalProjects, githubResult.repos); if (githubResult.error) { dispatch({ type: "SET_GITHUB_ERROR", payload: githubResult.error }); } const localCount = freshUnified.filter((r) => r.source === "local" || r.source === "both").length; const githubOnlyCount = freshUnified.filter((r) => r.source === "github").length; dispatch(setMessage(`${localCount} local, ${githubOnlyCount} GitHub repos`)); } catch (error) { console.error("Background refresh failed:", error); } finally { clearTimeout(refreshTimer); dispatch({ type: "SET_REFRESHING", payload: false }); isRefreshing = false; } } async function performLoad(options?: { forceRefresh?: boolean }): Promise { const forceRefresh = options?.forceRefresh ?? false; const state = getState(); const hasExistingData = state.unifiedRepos.length > 0; let loadingReleased = false; if (!hasExistingData) { dispatch(setLoading(true)); } dispatch({ type: "SET_GITHUB_ERROR", payload: null }); try { if (!forceRefresh) { const { hasCachedData, hasFreshLocalCache } = await loadFromCacheInstantly(); if (hasCachedData) { if (!hasFreshLocalCache) { // Refresh in background without blocking void refreshInBackground(); } loadingReleased = true; return; } } // No cache or forced refresh — full load with loading indicator if (hasExistingData) { dispatch(setLoading(true)); } dispatch({ type: "SET_GITHUB_LOADING", payload: true }); const localPromise = scanWithCache(config, { forceRefresh: true, onProjects: (partialProjects) => { if (partialProjects.length === 0) return; dispatchLocalResults(partialProjects, state.githubRepos, !loadingReleased); loadingReleased = true; }, }); const githubPromise = fetchGitHubReposWithCache({ includeArchived: false, includeForks: true, includeOrgs: true, }, config.cache.githubTtlSeconds); const resultsPromise = Promise.allSettled([localPromise, githubPromise]); const freshLocalProjects = await localPromise; // Local projects are useful on their own; don't hold the first render // behind the GitHub network request. dispatchLocalResults(freshLocalProjects, state.githubRepos, !loadingReleased); loadingReleased = true; const [, githubOutcome] = await resultsPromise; if (githubOutcome.status === "rejected") { throw githubOutcome.reason; } const githubResult = githubOutcome.value; const unified = dispatchLocalResults(freshLocalProjects, githubResult.repos); if (githubResult.error) { dispatch({ type: "SET_GITHUB_ERROR", payload: githubResult.error }); } const localCount = unified.filter((r) => r.source === "local" || r.source === "both").length; const githubOnlyCount = unified.filter((r) => r.source === "github").length; dispatch(setMessage(`Found ${localCount} local, ${githubOnlyCount} GitHub-only repos`)); } catch (error) { dispatch(setError(errorToString(error))); } finally { if (!loadingReleased) { dispatch(setLoading(false)); } dispatch({ type: "SET_GITHUB_LOADING", payload: false }); } } function load(options?: { forceRefresh?: boolean }): Promise { if (loadInFlight) return loadInFlight; const promise = performLoad(options); loadInFlight = promise; void promise.then( () => { if (loadInFlight === promise) loadInFlight = null; }, () => { if (loadInFlight === promise) loadInFlight = null; }, ); return promise; } async function refreshGitHub(): Promise { const state = getState(); const hasData = state.githubRepos.length > 0; if (!hasData) { dispatch({ type: "SET_GITHUB_LOADING", payload: true }); } else { dispatch({ type: "SET_REFRESHING", payload: true }); } dispatch({ type: "SET_GITHUB_ERROR", payload: null }); try { const { repos: freshGithubRepos, error } = await fetchGitHubReposWithCache({ includeArchived: false, includeForks: true, includeOrgs: true, }, 0); const unified = createUnifiedView(getState().projects, freshGithubRepos); dispatch({ type: "SET_GITHUB_REPOS", payload: freshGithubRepos }); dispatch({ type: "SET_UNIFIED_REPOS", payload: unified }); if (error) { dispatch({ type: "SET_GITHUB_ERROR", payload: error }); } } catch (error) { dispatch({ type: "SET_GITHUB_ERROR", payload: errorToString(error) }); } finally { dispatch({ type: "SET_GITHUB_LOADING", payload: false }); dispatch({ type: "SET_REFRESHING", payload: false }); } } async function backgroundRefresh(): Promise { const state = getState(); if (state.isLoading || state.actionInProgress || isRefreshing) { return; } await refreshInBackground(); } async function cloneRepo( repo: UnifiedRepo, targetDir: string, useSSH = true, ): Promise<{ success: boolean; path?: string; error?: string }> { if (!repo.github) { return { success: false, error: "No GitHub info available" }; } dispatch({ type: "CLONE_REPO_START", payload: repo.id }); dispatch(startAction(`Cloning ${repo.name}`)); const result = await cloneGitHubRepo(repo, targetDir, useSSH); dispatch(endAction()); if (result.success && result.path) { dispatch({ type: "CLONE_REPO_COMPLETE", payload: { id: repo.id, localPath: result.path } }); dispatch(setMessage(`Cloned ${repo.name} to ${result.path}`)); await load(); } else { dispatch({ type: "CLONE_REPO_FAILED", payload: { id: repo.id, error: result.error || "Clone failed" } }); dispatch(setError(`Failed to clone ${repo.name}: ${result.error}`)); } return result; } async function batchClone( repos: UnifiedRepo[], targetDir: string, useSSH = true, ): Promise<{ successful: number; failed: number }> { const githubRepos = repos.filter((r) => r.source === "github" && r.github); if (githubRepos.length === 0) { dispatch(setMessage("No GitHub-only repos to clone")); return { successful: 0, failed: 0 }; } dispatch(startAction(`Cloning ${githubRepos.length} repos`)); let successful = 0; let failed = 0; for (let i = 0; i < githubRepos.length; i++) { const repo = githubRepos[i]!; dispatch(updateProgress(i + 1, githubRepos.length)); const result = await cloneGitHubRepo(repo, targetDir, useSSH); if (result.success) { successful++; } else { failed++; } } dispatch(endAction()); dispatch(setMessage(`Cloned ${successful}/${githubRepos.length} repos`)); await load(); return { successful, failed }; } return { load, refreshGitHub, backgroundRefresh, cloneRepo, batchClone, }; }