gitforest 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/.bunignore +7 -0
  2. package/.github/workflows/ci.yml +73 -0
  3. package/CLAUDE.md +111 -0
  4. package/CONTRIBUTING.md +145 -0
  5. package/README.md +168 -0
  6. package/bun.lock +267 -0
  7. package/bunfig.toml +15 -0
  8. package/cli +0 -0
  9. package/config/gitforest.example.yaml +94 -0
  10. package/docs/ai/IMPROVEMENT_PLAN.md +341 -0
  11. package/docs/ai/VERIFICATION_REPORT.md +87 -0
  12. package/docs/ai/architecture.md +169 -0
  13. package/docs/ai/checks/check-2025-12-02-tests.md +40 -0
  14. package/docs/ai/checks/check-2025-12-02.md +55 -0
  15. package/docs/ai/checks/test-verification-report.md +85 -0
  16. package/docs/ai/implementation-guide.md +776 -0
  17. package/docs/ai/research/gitty-codebase-analysis.md +221 -0
  18. package/docs/ai/tickets/GENERAL-sitrep.md +30 -0
  19. package/docs/ai/tickets/TASK-database-tests-sitrep.md +25 -0
  20. package/docs/ai/tickets/TASK-deprecated-functions-sitrep.md +28 -0
  21. package/docs/ai/tickets/TASK-detail-modal-sitrep.md +28 -0
  22. package/docs/ai/tickets/TASK-filter-overlay-sitrep.md +24 -0
  23. package/docs/ai/tickets/TASK-github-service-sitrep.md +32 -0
  24. package/docs/ai/tickets/TASK-github-token-sitrep.md +51 -0
  25. package/docs/ai/tickets/TASK-hascommits-sitrep.md +35 -0
  26. package/docs/ai/tickets/TASK-keybindings-sitrep.md +26 -0
  27. package/docs/ai/tickets/TASK-layout-sitrep.md +25 -0
  28. package/docs/ai/tickets/TASK-markdown-sitrep.md +28 -0
  29. package/docs/ai/tickets/TASK-project-item-sitrep.md +79 -0
  30. package/docs/ai/tickets/TASK-sitrep.md +28 -0
  31. package/docs/ai/tickets/TASK-state-sitrep.md +26 -0
  32. package/docs/ai/tickets/TASK-types-sitrep.md +25 -0
  33. package/docs/ai/tickets/TASK-unified-item-fix-sitrep.md +26 -0
  34. package/docs/ai/tickets/TKT-001-sitrep.md +24 -0
  35. package/docs/ai/tickets/TKT-002-sitrep.md +25 -0
  36. package/docs/ai/tickets/TKT-003-git-service-refactoring-complete.md +46 -0
  37. package/docs/ai/tickets/TKT-003-git-service-refactoring-plan.md +135 -0
  38. package/docs/ai/tickets/TKT-003-sitrep.md +26 -0
  39. package/docs/ai/tickets/TKT-004-sitrep.md +27 -0
  40. package/docs/ai/tickets/TKT-005-sitrep.md +25 -0
  41. package/docs/ai/tickets/TKT-006-sitrep.md +26 -0
  42. package/docs/ai/tickets/TKT-007-sitrep.md +30 -0
  43. package/docs/ai/tickets/TKT-008-sitrep.md +32 -0
  44. package/docs/ai/tickets/TKT-009-sitrep.md +27 -0
  45. package/docs/ai/tickets/TKT-010-sitrep.md +27 -0
  46. package/docs/ai/tickets/TKT-011-sitrep.md +26 -0
  47. package/docs/ai/tickets/TKT-012-sitrep.md +25 -0
  48. package/docs/ai/tickets/sitreps/TASK-actions-sitrep.md +28 -0
  49. package/docs/ai/tickets/sitreps/TASK-actions-test-sitrep.md +25 -0
  50. package/docs/ai/tickets/sitreps/TASK-app-integration-sitrep.md +25 -0
  51. package/docs/ai/tickets/sitreps/TASK-background-fetch-sitrep.md +24 -0
  52. package/docs/ai/tickets/sitreps/TASK-background-fetch-test-sitrep.md +29 -0
  53. package/docs/ai/tickets/sitreps/TASK-batch-tests-sitrep.md +29 -0
  54. package/docs/ai/tickets/sitreps/TASK-bun-test-sitrep.md +26 -0
  55. package/docs/ai/tickets/sitreps/TASK-cache-tests-sitrep.md +30 -0
  56. package/docs/ai/tickets/sitreps/TASK-cli-tests-sitrep.md +28 -0
  57. package/docs/ai/tickets/sitreps/TASK-clone-error-handling-sitrep.md +26 -0
  58. package/docs/ai/tickets/sitreps/TASK-commands-tests-sitrep.md +25 -0
  59. package/docs/ai/tickets/sitreps/TASK-component-tests-1-sitrep.md +30 -0
  60. package/docs/ai/tickets/sitreps/TASK-configloader-tests-sitrep.md +25 -0
  61. package/docs/ai/tickets/sitreps/TASK-confirm-dialog-test-sitrep.md +29 -0
  62. package/docs/ai/tickets/sitreps/TASK-coverage-sitrep.md +95 -0
  63. package/docs/ai/tickets/sitreps/TASK-database-tests-summary.md +61 -0
  64. package/docs/ai/tickets/sitreps/TASK-error-boundary-sitrep.md +30 -0
  65. package/docs/ai/tickets/sitreps/TASK-error-tests-sitrep.md +27 -0
  66. package/docs/ai/tickets/sitreps/TASK-errors-tests-sitrep.md +25 -0
  67. package/docs/ai/tickets/sitreps/TASK-extract-reducer-sitrep.md +27 -0
  68. package/docs/ai/tickets/sitreps/TASK-filter-overlay-test-sitrep.md +25 -0
  69. package/docs/ai/tickets/sitreps/TASK-final-verification-sitrep.md +28 -0
  70. package/docs/ai/tickets/sitreps/TASK-fix-all-tests-sitrep.md +25 -0
  71. package/docs/ai/tickets/sitreps/TASK-fix-hooks-sitrep.md +26 -0
  72. package/docs/ai/tickets/sitreps/TASK-fix-remaining-tests-sitrep.md +25 -0
  73. package/docs/ai/tickets/sitreps/TASK-fix-test-failures-sitrep.md +26 -0
  74. package/docs/ai/tickets/sitreps/TASK-fix-tests-sitrep.md +24 -0
  75. package/docs/ai/tickets/sitreps/TASK-formatters-tests-sitrep.md +25 -0
  76. package/docs/ai/tickets/sitreps/TASK-git-timeouts-sitrep.md +29 -0
  77. package/docs/ai/tickets/sitreps/TASK-github-cache-test-sitrep.md +25 -0
  78. package/docs/ai/tickets/sitreps/TASK-githubcli-tests-sitrep.md +24 -0
  79. package/docs/ai/tickets/sitreps/TASK-gitstatus-tests-sitrep.md +24 -0
  80. package/docs/ai/tickets/sitreps/TASK-hooks-isolation-sitrep.md +27 -0
  81. package/docs/ai/tickets/sitreps/TASK-keybindings-tests-sitrep.md +25 -0
  82. package/docs/ai/tickets/sitreps/TASK-layout-tests-sitrep.md +25 -0
  83. package/docs/ai/tickets/sitreps/TASK-mock-factories-sitrep.md +27 -0
  84. package/docs/ai/tickets/sitreps/TASK-modal-tests-sitrep.md +32 -0
  85. package/docs/ai/tickets/sitreps/TASK-processbatch-fix-sitrep.md +27 -0
  86. package/docs/ai/tickets/sitreps/TASK-projectlist-tests-sitrep.md +30 -0
  87. package/docs/ai/tickets/sitreps/TASK-projectutils-tests-sitrep.md +25 -0
  88. package/docs/ai/tickets/sitreps/TASK-scanner-tests-sitrep.md +29 -0
  89. package/docs/ai/tickets/sitreps/TASK-select-all-sitrep.md +25 -0
  90. package/docs/ai/tickets/sitreps/TASK-shell-error-handling-sitrep.md +27 -0
  91. package/docs/ai/tickets/sitreps/TASK-store-tests-sitrep.md +25 -0
  92. package/docs/ai/tickets/sitreps/TASK-test-fixes-sitrep.md +26 -0
  93. package/docs/ai/tickets/sitreps/TASK-test-summary-sitrep.md +25 -0
  94. package/docs/ai/tickets/sitreps/TASK-test-verification-sitrep.md +27 -0
  95. package/docs/ai/tickets/sitreps/TASK-testsuite-sitrep.md +75 -0
  96. package/docs/ai/tickets/sitreps/TASK-unified-reducer-tests-sitrep.md +29 -0
  97. package/docs/ai/tickets/sitreps/TASK-unified-repos-test-sitrep.md +29 -0
  98. package/docs/ai/tickets/sitreps/TASK-unified-tests-sitrep.md +25 -0
  99. package/docs/ai/tickets/sitreps/TASK-useprojects-tests-sitrep.md +25 -0
  100. package/docs/ai/tickets/sitreps/TASK-utility-tests-sitrep.md +32 -0
  101. package/docs/ai/tickets/sitreps/TKT-003-git-service-refactoring-sitrep.md +64 -0
  102. package/docs/ai/tkt-001-fix-database-error.md +217 -0
  103. package/docs/ai/ui-enhancement-plan.md +562 -0
  104. package/package.json +50 -0
  105. package/src/app.tsx +43 -0
  106. package/src/cli/config.ts +94 -0
  107. package/src/cli/formatters.ts +632 -0
  108. package/src/cli/index.ts +583 -0
  109. package/src/components/CloneDialog.tsx +137 -0
  110. package/src/components/ColumnHeader.tsx +128 -0
  111. package/src/components/CommandPalette.tsx +120 -0
  112. package/src/components/ConfirmDialog.tsx +105 -0
  113. package/src/components/ErrorBoundary.tsx +128 -0
  114. package/src/components/FilterBar.tsx +71 -0
  115. package/src/components/FilterOptionsOverlay.tsx +131 -0
  116. package/src/components/HelpOverlay.tsx +120 -0
  117. package/src/components/Layout.tsx +379 -0
  118. package/src/components/MarkdownRenderer.tsx +127 -0
  119. package/src/components/ProgressBar.tsx +53 -0
  120. package/src/components/ProjectItem.tsx +143 -0
  121. package/src/components/ProjectList.tsx +90 -0
  122. package/src/components/RepoDetailModal.tsx +367 -0
  123. package/src/components/StatusBar.tsx +188 -0
  124. package/src/components/UnifiedProjectItem.tsx +436 -0
  125. package/src/components/ViewModeIndicator.tsx +37 -0
  126. package/src/components/onboarding/CompleteStep.tsx +82 -0
  127. package/src/components/onboarding/DirectoriesStep.test.tsx +52 -0
  128. package/src/components/onboarding/DirectoriesStep.tsx +847 -0
  129. package/src/components/onboarding/DirectoriesStep.unit.test.ts +345 -0
  130. package/src/components/onboarding/GitHubAuthStep.tsx +268 -0
  131. package/src/components/onboarding/OnboardingWizard.tsx +130 -0
  132. package/src/components/onboarding/WelcomeStep.tsx +69 -0
  133. package/src/config/loader.ts +263 -0
  134. package/src/config/onboarding.ts +67 -0
  135. package/src/constants.ts +96 -0
  136. package/src/db/index.ts +147 -0
  137. package/src/db/schema.ts +70 -0
  138. package/src/git/commands.ts +283 -0
  139. package/src/git/index.ts +2 -0
  140. package/src/git/operations.ts +93 -0
  141. package/src/git/service.ts +539 -0
  142. package/src/git/status.ts +84 -0
  143. package/src/git/types.ts +5 -0
  144. package/src/github/auth.ts +311 -0
  145. package/src/github/cache.ts +231 -0
  146. package/src/github/cli.ts +22 -0
  147. package/src/github/unified.ts +415 -0
  148. package/src/hooks/useBackgroundFetch.ts +76 -0
  149. package/src/hooks/useConfirmDialogActions.ts +120 -0
  150. package/src/hooks/useKeyBindings.ts +656 -0
  151. package/src/hooks/useProjects.ts +47 -0
  152. package/src/hooks/useUnifiedRepos.ts +317 -0
  153. package/src/index.tsx +494 -0
  154. package/src/operations/batch.ts +280 -0
  155. package/src/operations/commands.ts +140 -0
  156. package/src/operations/index.ts +37 -0
  157. package/src/scanner/index.ts +424 -0
  158. package/src/scanner/markers.ts +43 -0
  159. package/src/scanner/submodules.ts +61 -0
  160. package/src/services/git.ts +484 -0
  161. package/src/services/github.ts +676 -0
  162. package/src/services/index.ts +28 -0
  163. package/src/services/types.ts +99 -0
  164. package/src/state/actions.ts +175 -0
  165. package/src/state/reducer.ts +294 -0
  166. package/src/state/store.tsx +216 -0
  167. package/src/state/types.ts +8 -0
  168. package/src/types/index.ts +383 -0
  169. package/src/ui/theme.ts +44 -0
  170. package/src/utils/array.ts +14 -0
  171. package/src/utils/debug.ts +38 -0
  172. package/src/utils/errors.ts +17 -0
  173. package/src/utils/index.ts +8 -0
  174. package/src/utils/markdown.ts +230 -0
  175. package/src/utils/project-utils.ts +129 -0
  176. package/src/utils/rate-limiter.ts +134 -0
  177. package/src/utils/retry.ts +147 -0
  178. package/src/utils/timeout.ts +56 -0
  179. package/test/integration/app.isolated.tsx +240 -0
  180. package/test/integration/cli-commands.test.ts +287 -0
  181. package/test/integration/cli-validation.test.ts +264 -0
  182. package/test/integration/git-operations.test.ts +218 -0
  183. package/test/integration/scanner.test.ts +228 -0
  184. package/test/preload.ts +18 -0
  185. package/test/unit/cli/commands.test.ts +13 -0
  186. package/test/unit/cli/formatters.test.ts +1116 -0
  187. package/test/unit/cli/github-commands.test.ts +12 -0
  188. package/test/unit/components/CloneDialog.test.tsx +240 -0
  189. package/test/unit/components/ColumnHeader.test.tsx +128 -0
  190. package/test/unit/components/CommandPalette.test.tsx +355 -0
  191. package/test/unit/components/ConfirmDialog.test.tsx +111 -0
  192. package/test/unit/components/ErrorBoundary.test.tsx +139 -0
  193. package/test/unit/components/FilterBar.test.tsx +43 -0
  194. package/test/unit/components/FilterOptionsOverlay.test.tsx +197 -0
  195. package/test/unit/components/HelpOverlay.test.tsx +90 -0
  196. package/test/unit/components/Layout.test.tsx +328 -0
  197. package/test/unit/components/MarkdownRenderer.test.tsx +45 -0
  198. package/test/unit/components/ProgressBar.test.tsx +138 -0
  199. package/test/unit/components/ProjectItem.test.tsx +182 -0
  200. package/test/unit/components/ProjectList.test.tsx +311 -0
  201. package/test/unit/components/RepoDetailModal.test.tsx +445 -0
  202. package/test/unit/components/StatusBar.test.tsx +112 -0
  203. package/test/unit/components/UnifiedProjectItem.test.tsx +618 -0
  204. package/test/unit/components/ViewModeIndicator.test.tsx +137 -0
  205. package/test/unit/components/test-utils.tsx +63 -0
  206. package/test/unit/config/loader.test.ts +692 -0
  207. package/test/unit/db/database.test.ts +978 -0
  208. package/test/unit/db/index.test.ts +314 -0
  209. package/test/unit/fixtures/setup.ts +186 -0
  210. package/test/unit/git/commands-untested.test.ts +205 -0
  211. package/test/unit/git/commands.test.ts +269 -0
  212. package/test/unit/git/operations.test.ts +322 -0
  213. package/test/unit/git/status.test.ts +219 -0
  214. package/test/unit/github/auth.test.ts +317 -0
  215. package/test/unit/github/cache.test.ts +1028 -0
  216. package/test/unit/github/cli.test.ts +135 -0
  217. package/test/unit/github/unified.test.ts +1201 -0
  218. package/test/unit/graceful-shutdown.test.ts +83 -0
  219. package/test/unit/hooks/useBackgroundFetch.test.tsx +239 -0
  220. package/test/unit/hooks/useConfirmDialogActions.test.tsx +81 -0
  221. package/test/unit/hooks/useKeyBindings.isolated.ts +715 -0
  222. package/test/unit/hooks/useProjects.test.tsx +186 -0
  223. package/test/unit/hooks/useUnifiedRepos-simple.test.tsx +115 -0
  224. package/test/unit/hooks/useUnifiedRepos.test.tsx +177 -0
  225. package/test/unit/mocks/config.ts +109 -0
  226. package/test/unit/mocks/git-service.ts +274 -0
  227. package/test/unit/mocks/github-service.ts +250 -0
  228. package/test/unit/mocks/index.ts +72 -0
  229. package/test/unit/mocks/project.ts +148 -0
  230. package/test/unit/mocks/state-mocks.ts +187 -0
  231. package/test/unit/mocks/unified.ts +169 -0
  232. package/test/unit/operations/batch.test.ts +216 -0
  233. package/test/unit/operations/commands.test.ts +550 -0
  234. package/test/unit/scanner/errors.test.ts +297 -0
  235. package/test/unit/scanner/index.test.ts +1011 -0
  236. package/test/unit/scanner/markers.test.ts +150 -0
  237. package/test/unit/scanner/submodules.test.ts +99 -0
  238. package/test/unit/services/git-errors.test.ts +190 -0
  239. package/test/unit/services/git.test.ts +442 -0
  240. package/test/unit/services/github-errors.test.ts +293 -0
  241. package/test/unit/services/github.test.ts +200 -0
  242. package/test/unit/state/actions.test.ts +217 -0
  243. package/test/unit/state/reducer.test.ts +745 -0
  244. package/test/unit/state/store.test.tsx +711 -0
  245. package/test/unit/types/commands.test.ts +220 -0
  246. package/test/unit/types/schema.test.ts +179 -0
  247. package/test/unit/utils/array.test.ts +73 -0
  248. package/test/unit/utils/debug.test.ts +23 -0
  249. package/test/unit/utils/errors.test.ts +295 -0
  250. package/test/unit/utils/markdown.test.ts +163 -0
  251. package/test/unit/utils/project-utils.test.ts +756 -0
  252. package/test/unit/utils/rate-limiter.test.ts +256 -0
  253. package/test/unit/utils/retry.test.ts +165 -0
  254. package/test/unit/utils/strip-ansi.ts +13 -0
  255. package/test/unit/utils/timeout.test.ts +93 -0
  256. package/tsconfig.json +29 -0
@@ -0,0 +1,317 @@
1
+ import { useEffect, useCallback, useRef } from "react";
2
+ import { useStore } from "../state/store.tsx";
3
+ import {
4
+ setProjects,
5
+ setLoading,
6
+ setError,
7
+ setMessage,
8
+ startAction,
9
+ endAction,
10
+ updateProgress,
11
+ } from "../state/actions.ts";
12
+ import { sortProjects, scanWithCache } from "../scanner/index.ts";
13
+ import { cloneGitHubRepo, createUnifiedView, fetchUnifiedRepos } from "../github/unified.ts";
14
+ import { fetchGitHubReposWithCache, loadGitHubReposFromCache } from "../github/cache.ts";
15
+ import type { GitforestConfig, UnifiedRepo, Project, GitHubRepoInfo } from "../types/index.ts";
16
+ import { errorToString } from "../utils/errors.ts";
17
+
18
+ export function useUnifiedRepos(config: GitforestConfig) {
19
+ const { state, dispatch } = useStore();
20
+ const { sortBy, sortDirection } = state;
21
+
22
+ // Track if we've done initial load
23
+ const hasLoadedRef = useRef(false);
24
+ const isRefreshingRef = useRef(false);
25
+
26
+ /**
27
+ * Load cached data immediately without showing loading state
28
+ * Returns true if cache was available
29
+ */
30
+ const loadFromCacheInstantly = useCallback(async (): Promise<{
31
+ localProjects: Project[];
32
+ githubRepos: GitHubRepoInfo[];
33
+ hasCachedData: boolean;
34
+ }> => {
35
+ try {
36
+ // Load both caches in parallel
37
+ const [localProjects, githubRepos] = await Promise.all([
38
+ scanWithCache(config),
39
+ loadGitHubReposFromCache(),
40
+ ]);
41
+
42
+ const hasCachedData = localProjects.length > 0;
43
+
44
+ if (hasCachedData) {
45
+ // Show cached data immediately
46
+ const sorted = sortProjects(localProjects, sortBy, sortDirection);
47
+ dispatch(setProjects(sorted));
48
+
49
+ // Create unified view with cached GitHub data
50
+ const unified = createUnifiedView(localProjects, githubRepos);
51
+ dispatch({ type: "SET_GITHUB_REPOS", payload: githubRepos });
52
+ dispatch({ type: "SET_UNIFIED_REPOS", payload: unified });
53
+
54
+ // Don't show loading state since we have data
55
+ dispatch(setLoading(false));
56
+ }
57
+
58
+ return { localProjects, githubRepos, hasCachedData };
59
+ } catch (error) {
60
+ console.error("Failed to load from cache:", error);
61
+ return { localProjects: [], githubRepos: [], hasCachedData: false };
62
+ }
63
+ }, [config, dispatch, sortBy, sortDirection]);
64
+
65
+ /**
66
+ * Refresh data in background without blocking UI
67
+ */
68
+ const refreshInBackground = useCallback(async (
69
+ existingLocalProjects: Project[],
70
+ existingGithubRepos: GitHubRepoInfo[]
71
+ ) => {
72
+ // Prevent concurrent refreshes
73
+ if (isRefreshingRef.current) return;
74
+ isRefreshingRef.current = true;
75
+
76
+ // Only raise refreshing flag if the run lasts beyond a small delay
77
+ let refreshTimer: ReturnType<typeof setTimeout> | null = null;
78
+ refreshTimer = setTimeout(() => {
79
+ dispatch({ type: "SET_REFRESHING", payload: true });
80
+ }, 400);
81
+ try {
82
+ // Refresh local projects and GitHub repos in parallel
83
+ const [freshLocalProjects, githubResult] = await Promise.all([
84
+ scanWithCache(config, { forceRefresh: true }),
85
+ fetchGitHubReposWithCache({
86
+ includeArchived: false,
87
+ includeForks: true,
88
+ includeOrgs: true,
89
+ }, 0), // TTL=0 forces fresh fetch
90
+ ]);
91
+
92
+ const freshSorted = sortProjects(freshLocalProjects, sortBy, sortDirection);
93
+ dispatch(setProjects(freshSorted));
94
+
95
+ // Update unified view
96
+ const freshUnified = createUnifiedView(freshLocalProjects, githubResult.repos);
97
+ dispatch({ type: "SET_GITHUB_REPOS", payload: githubResult.repos });
98
+ dispatch({ type: "SET_UNIFIED_REPOS", payload: freshUnified });
99
+
100
+ if (githubResult.error) {
101
+ dispatch({ type: "SET_GITHUB_ERROR", payload: githubResult.error });
102
+ }
103
+
104
+ // Show subtle refresh complete message
105
+ const localCount = freshUnified.filter(r => r.source === "local" || r.source === "both").length;
106
+ const githubOnlyCount = freshUnified.filter(r => r.source === "github").length;
107
+ dispatch(setMessage(`${localCount} local, ${githubOnlyCount} GitHub repos`));
108
+
109
+ } catch (error) {
110
+ console.error("Background refresh failed:", error);
111
+ // Don't show error to user since we have cached data
112
+ } finally {
113
+ if (refreshTimer) {
114
+ clearTimeout(refreshTimer);
115
+ }
116
+ dispatch({ type: "SET_REFRESHING", payload: false });
117
+ isRefreshingRef.current = false;
118
+ }
119
+ }, [config, dispatch, sortBy, sortDirection]);
120
+
121
+ /**
122
+ * Main load function - smart loading with cache-first strategy
123
+ */
124
+ const loadUnifiedRepos = useCallback(async () => {
125
+ // If we already have data, don't show loading state
126
+ const hasExistingData = state.unifiedRepos.length > 0;
127
+
128
+ if (!hasExistingData) {
129
+ dispatch(setLoading(true));
130
+ }
131
+ dispatch({ type: "SET_GITHUB_ERROR", payload: null });
132
+
133
+ try {
134
+ // Step 1: Load from cache instantly
135
+ const { localProjects, githubRepos, hasCachedData } = await loadFromCacheInstantly();
136
+
137
+ if (hasCachedData) {
138
+ // We have cached data - refresh in background
139
+ hasLoadedRef.current = true;
140
+
141
+ // Check if cache is stale and needs refresh
142
+ const now = Date.now();
143
+ const isLocalStale = localProjects.some(p =>
144
+ now - p.lastScanned.getTime() > config.cache.ttlSeconds * 1000
145
+ );
146
+
147
+ if (isLocalStale) {
148
+ // Refresh in background without blocking
149
+ refreshInBackground(localProjects, githubRepos);
150
+ }
151
+ } else {
152
+ // No cache - do full load with loading indicator
153
+ dispatch(setLoading(true));
154
+ dispatch({ type: "SET_GITHUB_LOADING", payload: true });
155
+
156
+ const [freshLocalProjects, githubResult] = await Promise.all([
157
+ scanWithCache(config, { forceRefresh: true }),
158
+ fetchGitHubReposWithCache({
159
+ includeArchived: false,
160
+ includeForks: true,
161
+ includeOrgs: true,
162
+ }, config.cache.githubTtlSeconds),
163
+ ]);
164
+
165
+ const sorted = sortProjects(freshLocalProjects, sortBy, sortDirection);
166
+ dispatch(setProjects(sorted));
167
+
168
+ const unified = createUnifiedView(freshLocalProjects, githubResult.repos);
169
+ dispatch({ type: "SET_GITHUB_REPOS", payload: githubResult.repos });
170
+ dispatch({ type: "SET_UNIFIED_REPOS", payload: unified });
171
+
172
+ if (githubResult.error) {
173
+ dispatch({ type: "SET_GITHUB_ERROR", payload: githubResult.error });
174
+ }
175
+
176
+ const localCount = unified.filter(r => r.source === "local" || r.source === "both").length;
177
+ const githubOnlyCount = unified.filter(r => r.source === "github").length;
178
+ dispatch(setMessage(`Found ${localCount} local, ${githubOnlyCount} GitHub-only repos`));
179
+
180
+ hasLoadedRef.current = true;
181
+ }
182
+ } catch (error) {
183
+ dispatch(setError(errorToString(error)));
184
+ } finally {
185
+ dispatch(setLoading(false));
186
+ dispatch({ type: "SET_GITHUB_LOADING", payload: false });
187
+ }
188
+ }, [config, dispatch, sortBy, sortDirection, state.unifiedRepos.length, loadFromCacheInstantly, refreshInBackground]);
189
+
190
+ // Refresh only GitHub repos (faster, doesn't rescan filesystem)
191
+ // Uses existing local projects to avoid re-scanning
192
+ const refreshGitHub = useCallback(async () => {
193
+ // Don't show blocking loading state if we have data
194
+ const hasData = state.githubRepos.length > 0;
195
+ if (!hasData) {
196
+ dispatch({ type: "SET_GITHUB_LOADING", payload: true });
197
+ } else {
198
+ dispatch({ type: "SET_REFRESHING", payload: true });
199
+ }
200
+ dispatch({ type: "SET_GITHUB_ERROR", payload: null });
201
+
202
+ try {
203
+ // Force fresh fetch (TTL=0)
204
+ const { repos: freshGithubRepos, error } = await fetchGitHubReposWithCache({
205
+ includeArchived: false,
206
+ includeForks: true,
207
+ includeOrgs: true,
208
+ }, 0);
209
+
210
+ // Update unified view with fresh GitHub data
211
+ const unified = createUnifiedView(state.projects, freshGithubRepos);
212
+ dispatch({ type: "SET_GITHUB_REPOS", payload: freshGithubRepos });
213
+ dispatch({ type: "SET_UNIFIED_REPOS", payload: unified });
214
+
215
+ if (error) {
216
+ dispatch({ type: "SET_GITHUB_ERROR", payload: error });
217
+ }
218
+ } catch (error) {
219
+ dispatch({ type: "SET_GITHUB_ERROR", payload: errorToString(error) });
220
+ } finally {
221
+ dispatch({ type: "SET_GITHUB_LOADING", payload: false });
222
+ dispatch({ type: "SET_REFRESHING", payload: false });
223
+ }
224
+ }, [state.projects, state.githubRepos.length, dispatch]);
225
+
226
+ // Clone a GitHub repo
227
+ const cloneRepo = useCallback(async (
228
+ repo: UnifiedRepo,
229
+ targetDir: string,
230
+ useSSH: boolean = true
231
+ ) => {
232
+ if (!repo.github) {
233
+ return { success: false, error: "No GitHub info available" };
234
+ }
235
+
236
+ dispatch({ type: "CLONE_REPO_START", payload: repo.id });
237
+ dispatch(startAction(`Cloning ${repo.name}`));
238
+
239
+ const result = await cloneGitHubRepo(repo, targetDir, useSSH);
240
+
241
+ dispatch(endAction());
242
+
243
+ if (result.success && result.path) {
244
+ dispatch({ type: "CLONE_REPO_COMPLETE", payload: { id: repo.id, localPath: result.path } });
245
+ dispatch(setMessage(`Cloned ${repo.name} to ${result.path}`));
246
+ // Refresh to show the newly cloned repo
247
+ await loadUnifiedRepos();
248
+ } else {
249
+ dispatch({ type: "CLONE_REPO_FAILED", payload: { id: repo.id, error: result.error || "Clone failed" } });
250
+ dispatch(setError(`Failed to clone ${repo.name}: ${result.error}`));
251
+ }
252
+
253
+ return result;
254
+ }, [dispatch, loadUnifiedRepos]);
255
+
256
+ // Batch clone multiple repos
257
+ const batchClone = useCallback(async (
258
+ repos: UnifiedRepo[],
259
+ targetDir: string,
260
+ useSSH: boolean = true
261
+ ) => {
262
+ const githubRepos = repos.filter(r => r.source === "github" && r.github);
263
+
264
+ if (githubRepos.length === 0) {
265
+ dispatch(setMessage("No GitHub-only repos to clone"));
266
+ return { successful: 0, failed: 0 };
267
+ }
268
+
269
+ dispatch(startAction(`Cloning ${githubRepos.length} repos`));
270
+
271
+ let successful = 0;
272
+ let failed = 0;
273
+
274
+ for (let i = 0; i < githubRepos.length; i++) {
275
+ const repo = githubRepos[i]!;
276
+ dispatch(updateProgress(i + 1, githubRepos.length));
277
+
278
+ const result = await cloneGitHubRepo(repo, targetDir, useSSH);
279
+
280
+ if (result.success) {
281
+ successful++;
282
+ } else {
283
+ failed++;
284
+ }
285
+ }
286
+
287
+ dispatch(endAction());
288
+ dispatch(setMessage(`Cloned ${successful}/${githubRepos.length} repos`));
289
+
290
+ // Refresh to show newly cloned repos
291
+ await loadUnifiedRepos();
292
+
293
+ return { successful, failed };
294
+ }, [dispatch, loadUnifiedRepos]);
295
+
296
+ // Background refresh - delegates to refreshInBackground
297
+ const backgroundRefresh = useCallback(async () => {
298
+ // Don't refresh if we're already loading or during an action
299
+ if (state.isLoading || state.actionInProgress || isRefreshingRef.current) {
300
+ return;
301
+ }
302
+ await refreshInBackground(state.projects, state.githubRepos);
303
+ }, [state.isLoading, state.actionInProgress, state.projects, state.githubRepos, refreshInBackground]);
304
+
305
+ // Load on mount
306
+ useEffect(() => {
307
+ loadUnifiedRepos();
308
+ }, []);
309
+
310
+ return {
311
+ loadUnifiedRepos,
312
+ refreshGitHub,
313
+ cloneRepo,
314
+ batchClone,
315
+ backgroundRefresh,
316
+ };
317
+ }