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,656 @@
1
+ import { useInput, useApp } from "ink";
2
+ import { useStore, useFilteredProjects, useSelectedProjects, useFilteredUnifiedRepos, useSelectedUnifiedRepos } from "../state/store.tsx";
3
+ import {
4
+ moveCursor,
5
+ toggleSelection,
6
+ selectAll,
7
+ deselectAll,
8
+ setMode,
9
+ setFilter,
10
+ cycleSort,
11
+ setSort,
12
+ setMessage,
13
+ startAction,
14
+ endAction,
15
+ updateProgress,
16
+ } from "../state/actions.ts";
17
+ import { batchPull as defaultBatchPull, batchPush as defaultBatchPush, batchFetch as defaultBatchFetch } from "../operations/batch.ts";
18
+ import { initGitInProject as defaultInitGitInProject } from "../git/operations.ts";
19
+ import { executeCommand as defaultExecuteCommand, findCommandByKey as defaultFindCommandByKey } from "../operations/commands.ts";
20
+ import { errorToString } from "../utils/errors.ts";
21
+ import type { GitforestConfig, ConfirmDialogState, QuickFilter, ViewMode, DetailModalState, UnifiedRepo, CommandConfig, Project, BatchResult, OperationResult } from "../types/index.ts";
22
+
23
+ /**
24
+ * Dependencies that can be injected for testing
25
+ */
26
+ export interface KeyBindingDeps {
27
+ batchPull: (projects: Project[], options?: { concurrency?: number; onProgress?: (current: number, total: number) => void }) => Promise<BatchResult>;
28
+ batchPush: (projects: Project[], options?: { concurrency?: number; onProgress?: (current: number, total: number) => void }) => Promise<BatchResult>;
29
+ batchFetch: (projects: Project[], options?: { concurrency?: number; onProgress?: (current: number, total: number) => void }) => Promise<BatchResult>;
30
+ initGitInProject: (path: string) => Promise<OperationResult>;
31
+ executeCommand: (command: CommandConfig, projectPath: string) => Promise<{ success: boolean; output?: string; error?: string }>;
32
+ findCommandByKey: (commands: CommandConfig[], key: string) => CommandConfig | undefined;
33
+ }
34
+
35
+ interface UseKeyBindingsOptions {
36
+ config: GitforestConfig;
37
+ onRefresh: () => Promise<void>;
38
+ deps?: Partial<KeyBindingDeps>;
39
+ }
40
+
41
+ export function useKeyBindings({ config, onRefresh, deps }: UseKeyBindingsOptions) {
42
+ const { state, dispatch } = useStore();
43
+ const { exit } = useApp();
44
+ const filteredProjects = useFilteredProjects();
45
+ const selectedProjects = useSelectedProjects();
46
+ const filteredUnifiedRepos = useFilteredUnifiedRepos();
47
+ const selectedUnifiedRepos = useSelectedUnifiedRepos();
48
+
49
+ // Use injected dependencies or defaults
50
+ const batchPull = deps?.batchPull ?? defaultBatchPull;
51
+ const batchPush = deps?.batchPush ?? defaultBatchPush;
52
+ const batchFetch = deps?.batchFetch ?? defaultBatchFetch;
53
+ const initGitInProject = deps?.initGitInProject ?? defaultInitGitInProject;
54
+ const executeCommand = deps?.executeCommand ?? defaultExecuteCommand;
55
+ const findCommandByKey = deps?.findCommandByKey ?? defaultFindCommandByKey;
56
+
57
+ // Helper function to execute a custom command on selected repos
58
+ async function handleCommandExecution(command: CommandConfig, repos: UnifiedRepo[]) {
59
+ // If no repos explicitly selected, use the current cursor position
60
+ let targetRepos = repos;
61
+ if (targetRepos.length === 0) {
62
+ const currentRepo = filteredUnifiedRepos[state.cursorIndex];
63
+ if (currentRepo) {
64
+ targetRepos = [currentRepo];
65
+ }
66
+ }
67
+
68
+ // Filter to repos with local paths
69
+ const localRepos = targetRepos.filter((r) => r.localPath || r.local?.path);
70
+
71
+ if (localRepos.length === 0) {
72
+ dispatch(setMessage("No local projects selected"));
73
+ return;
74
+ }
75
+
76
+ const projectPath = localRepos[0]!.localPath || localRepos[0]!.local!.path;
77
+
78
+ dispatch(startAction(`Running: ${command.name}`));
79
+
80
+ try {
81
+ const result = await executeCommand(command, projectPath);
82
+ dispatch(endAction());
83
+
84
+ if (result.success) {
85
+ if (result.output) {
86
+ // Show truncated output
87
+ const shortOutput = result.output.length > 50
88
+ ? result.output.slice(0, 50) + "..."
89
+ : result.output;
90
+ dispatch(setMessage(`${command.name}: ${shortOutput}`));
91
+ } else {
92
+ dispatch(setMessage(`${command.name}: Done`));
93
+ }
94
+ } else {
95
+ dispatch(setMessage(`${command.name} failed: ${result.error}`));
96
+ }
97
+ } catch (error) {
98
+ dispatch(endAction());
99
+ dispatch(setMessage(`${command.name} failed: ${error instanceof Error ? error.message : String(error)}`));
100
+ }
101
+ }
102
+
103
+ // Helper function to fetch README content
104
+ async function fetchReadme(repo: UnifiedRepo): Promise<{ content: string | null; error: string | null }> {
105
+ // Try local README first
106
+ if (repo.localPath) {
107
+ try {
108
+ const readmePath = `${repo.localPath}/README.md`;
109
+ const file = Bun.file(readmePath);
110
+ if (await file.exists()) {
111
+ const content = await file.text();
112
+ return { content, error: null };
113
+ }
114
+ } catch {}
115
+ }
116
+
117
+ // Try GitHub API
118
+ if (repo.github) {
119
+ try {
120
+ const response = await fetch(
121
+ `https://api.github.com/repos/${repo.github.fullName}/readme`,
122
+ {
123
+ headers: {
124
+ 'Accept': 'application/vnd.github.raw',
125
+ 'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`,
126
+ },
127
+ }
128
+ );
129
+ if (response.ok) {
130
+ const content = await response.text();
131
+ return { content, error: null };
132
+ }
133
+ } catch {}
134
+ }
135
+
136
+ return { content: null, error: "README not found" };
137
+ }
138
+
139
+ useInput(async (input, key) => {
140
+ const { mode, cursorIndex, selectedIndices } = state;
141
+
142
+ // Handle filter mode separately
143
+ if (mode === "filter") {
144
+ if (key.escape) {
145
+ dispatch(setMode("normal"));
146
+ dispatch(setFilter(""));
147
+ return;
148
+ }
149
+ if (key.return) {
150
+ dispatch(setMode("normal"));
151
+ return;
152
+ }
153
+ // Let TextInput handle other keys
154
+ return;
155
+ }
156
+
157
+ // Handle detail modal mode
158
+ if (mode === "detail") {
159
+ if (key.escape) {
160
+ dispatch({ type: "HIDE_DETAIL_MODAL" });
161
+ return;
162
+ }
163
+ // j/k to scroll README
164
+ if (input === "j") {
165
+ dispatch({ type: "UPDATE_DETAIL_MODAL", payload: {
166
+ readmeScrollOffset: (state.detailModal?.readmeScrollOffset ?? 0) + 1
167
+ }});
168
+ return;
169
+ }
170
+ if (input === "k") {
171
+ dispatch({ type: "UPDATE_DETAIL_MODAL", payload: {
172
+ readmeScrollOffset: Math.max(0, (state.detailModal?.readmeScrollOffset ?? 0) - 1)
173
+ }});
174
+ return;
175
+ }
176
+ // Actions in modal - these should trigger the onAction callback
177
+ // For now, just close modal and perform action
178
+ if (input === "p" || input === "P" || input === "f") {
179
+ // Let the existing handlers work, but close modal first
180
+ dispatch({ type: "HIDE_DETAIL_MODAL" });
181
+ // Don't return - let it fall through to normal handling
182
+ }
183
+ if (input === "o") {
184
+ // Open in browser - need to implement
185
+ const repo = state.detailModal?.repo;
186
+ if (repo?.github?.htmlUrl) {
187
+ // Use Bun to open URL
188
+ try {
189
+ await Bun.$`open ${repo.github.htmlUrl}`.quiet();
190
+ } catch (error) {
191
+ dispatch(setMessage(`Failed to open browser: ${errorToString(error)}`));
192
+ }
193
+ }
194
+ return;
195
+ }
196
+ if (input === "d") {
197
+ // Open in editor
198
+ const repo = state.detailModal?.repo;
199
+ if (repo?.localPath) {
200
+ const editor = process.env.EDITOR || "code";
201
+ try {
202
+ await Bun.$`${editor} ${repo.localPath}`.quiet();
203
+ } catch (error) {
204
+ dispatch(setMessage(`Failed to open editor: ${errorToString(error)}`));
205
+ }
206
+ }
207
+ return;
208
+ }
209
+ return; // Don't process other keys in detail mode
210
+ }
211
+
212
+ // Handle filter options mode
213
+ if (mode === "filter-options") {
214
+ // Any key closes the overlay
215
+ dispatch(setMode("normal"));
216
+ return;
217
+ }
218
+
219
+ // Handle help mode
220
+ if (mode === "help") {
221
+ dispatch(setMode("normal"));
222
+ return;
223
+ }
224
+
225
+ // Handle clone dialog mode
226
+ if (mode === "clone") {
227
+ if (key.escape) {
228
+ dispatch({ type: "HIDE_CLONE_DIALOG" });
229
+ dispatch(setMode("normal"));
230
+ return;
231
+ }
232
+ // Let CloneDialog handle other keys
233
+ return;
234
+ }
235
+
236
+ // Handle command palette mode
237
+ if (mode === "command-palette") {
238
+ if (key.escape) {
239
+ dispatch(setMode("normal"));
240
+ return;
241
+ }
242
+
243
+ // Find and execute command by key
244
+ const command = findCommandByKey(config.commands, input);
245
+ if (command) {
246
+ await handleCommandExecution(command, selectedUnifiedRepos);
247
+ dispatch(setMode("normal"));
248
+ return;
249
+ }
250
+ return;
251
+ }
252
+
253
+ // Global keys that work in any mode
254
+ if (input === "q" || (key.ctrl && input === "c")) {
255
+ exit();
256
+ return;
257
+ }
258
+
259
+ if (input === "?") {
260
+ dispatch(setMode("help"));
261
+ return;
262
+ }
263
+
264
+ // F key for filter options overlay
265
+ if (input === "F") {
266
+ dispatch(setMode("filter-options"));
267
+ return;
268
+ }
269
+
270
+ // x key for command palette
271
+ if (input === "x") {
272
+ if (config.commands.length === 0) {
273
+ dispatch(setMessage("No commands configured"));
274
+ return;
275
+ }
276
+ dispatch(setMode("command-palette"));
277
+ return;
278
+ }
279
+
280
+ // Tab - cycle view mode
281
+ if (key.tab) {
282
+ const modes: ViewMode[] = ["local", "github", "combined"];
283
+ const unifiedState = state as any;
284
+ const currentMode: ViewMode = unifiedState.viewMode || "local";
285
+ const currentIdx = modes.indexOf(currentMode);
286
+ if (currentIdx === -1) return; // Safety check
287
+ const nextIdx = (currentIdx + 1) % modes.length;
288
+ const nextMode = modes[nextIdx]!;
289
+ dispatch({ type: "SET_VIEW_MODE", payload: nextMode });
290
+ const modeLabels: Record<ViewMode, string> = { local: "Local only", github: "GitHub only", combined: "All repos" };
291
+ dispatch(setMessage(`View: ${modeLabels[nextMode]}`));
292
+ return;
293
+ }
294
+
295
+ // Navigation
296
+ if (input === "j" || key.downArrow) {
297
+ dispatch(moveCursor(cursorIndex + 1));
298
+ return;
299
+ }
300
+
301
+ if (input === "k" || key.upArrow) {
302
+ dispatch(moveCursor(cursorIndex - 1));
303
+ return;
304
+ }
305
+
306
+ // Enter - open detail modal
307
+ if (key.return) {
308
+ const currentRepo = filteredUnifiedRepos[cursorIndex];
309
+ if (currentRepo) {
310
+ // Fetch README content (async)
311
+ const detailState: DetailModalState = {
312
+ repo: currentRepo,
313
+ readmeContent: null,
314
+ readmeLoading: true,
315
+ readmeError: null,
316
+ readmeScrollOffset: 0,
317
+ };
318
+ dispatch({ type: "SHOW_DETAIL_MODAL", payload: detailState });
319
+
320
+ // Fetch README in background
321
+ fetchReadme(currentRepo).then(({ content, error }) => {
322
+ dispatch({ type: "UPDATE_DETAIL_MODAL", payload: {
323
+ readmeContent: content,
324
+ readmeLoading: false,
325
+ readmeError: error,
326
+ }});
327
+ });
328
+ }
329
+ return;
330
+ }
331
+
332
+ if (input === "g") {
333
+ dispatch(moveCursor(0));
334
+ return;
335
+ }
336
+
337
+ if (input === "G") {
338
+ const unifiedState = state as any;
339
+ const currentViewMode = unifiedState.viewMode || "combined";
340
+ const itemCount = currentViewMode === "local" ? filteredProjects.length : filteredUnifiedRepos.length;
341
+ dispatch(moveCursor(itemCount - 1));
342
+ return;
343
+ }
344
+
345
+ // Selection
346
+ if (input === " ") {
347
+ dispatch(toggleSelection(cursorIndex));
348
+ return;
349
+ }
350
+
351
+ if (input === "a") {
352
+ const unifiedState = state as any;
353
+ const currentViewMode = unifiedState.viewMode || "combined";
354
+ const itemCount = currentViewMode === "local" ? filteredProjects.length : filteredUnifiedRepos.length;
355
+ if (selectedIndices.size === itemCount) {
356
+ dispatch(deselectAll());
357
+ } else {
358
+ dispatch(selectAll());
359
+ }
360
+ return;
361
+ }
362
+
363
+ // Filter
364
+ if (input === "/") {
365
+ dispatch(setMode("filter"));
366
+ return;
367
+ }
368
+
369
+ // Sort - 's' cycles field, 'S' reverses direction
370
+ if (input === "s") {
371
+ dispatch(cycleSort());
372
+ return;
373
+ }
374
+
375
+ if (input === "S") {
376
+ const newDirection = state.sortDirection === 'desc' ? 'asc' : 'desc';
377
+ dispatch(setSort(state.sortBy, newDirection));
378
+ dispatch(setMessage(`Sort: ${state.sortBy} ${newDirection === 'desc' ? '↓' : '↑'}`));
379
+ return;
380
+ }
381
+
382
+ // Quick filters (1=dirty, 2=unpushed, 3=no-remote, 4=github-only, 5=local-only, 6=private, 7=public, 8=archived, 9=forks, 0=all)
383
+ const quickFilterMap: Record<string, QuickFilter> = {
384
+ "0": "all",
385
+ "1": "dirty",
386
+ "2": "unpushed",
387
+ "3": "no-remote",
388
+ "4": "github-only",
389
+ "5": "local-only",
390
+ "6": "private",
391
+ "7": "public",
392
+ "8": "archived",
393
+ "9": "forks",
394
+ };
395
+
396
+ if (input in quickFilterMap) {
397
+ const newFilter = quickFilterMap[input]!;
398
+ dispatch({ type: "SET_QUICK_FILTER", payload: newFilter });
399
+ const filterNames: Record<QuickFilter, string> = {
400
+ all: "All projects",
401
+ dirty: "Dirty projects",
402
+ unpushed: "Unpushed commits",
403
+ "no-remote": "No remote",
404
+ "github-only": "GitHub only",
405
+ "local-only": "Local only",
406
+ private: "Private repos",
407
+ public: "Public repos",
408
+ archived: "Archived repos",
409
+ forks: "Forked repos",
410
+ };
411
+ dispatch(setMessage(`Filter: ${filterNames[newFilter]}`));
412
+ return;
413
+ }
414
+
415
+ // Refresh
416
+ if (input === "r") {
417
+ dispatch({ type: "SET_REFRESHING", payload: true });
418
+ await onRefresh();
419
+ dispatch({ type: "SET_REFRESHING", payload: false });
420
+ dispatch(setMessage("Refresh complete"));
421
+ return;
422
+ }
423
+
424
+ // Git operations
425
+ if (input === "p") {
426
+ // Push selected
427
+ const gitProjects = selectedProjects.filter(
428
+ (p) => p.type === "git" && p.status?.hasRemote && p.status?.isAhead
429
+ );
430
+
431
+ if (gitProjects.length === 0) {
432
+ dispatch(setMessage("No projects to push"));
433
+ return;
434
+ }
435
+
436
+ dispatch(startAction("Pushing"));
437
+
438
+ const result = await batchPush(gitProjects, { concurrency: config.scan.concurrency, onProgress: (current, total) => {
439
+ dispatch(updateProgress(current, total));
440
+ }});
441
+
442
+ dispatch(endAction());
443
+
444
+ // Show detailed push result
445
+ if (result.failed > 0) {
446
+ const failedNames = result.results
447
+ .filter(r => !r.success)
448
+ .map((_, i) => gitProjects[i]?.name)
449
+ .filter(Boolean)
450
+ .slice(0, 3)
451
+ .join(", ");
452
+ const moreCount = result.failed > 3 ? ` +${result.failed - 3} more` : "";
453
+ dispatch(setMessage(`Pushed ${result.successful}/${result.total} (failed: ${failedNames}${moreCount})`));
454
+ } else {
455
+ const pushedNames = gitProjects.slice(0, 3).map(p => p.name).join(", ");
456
+ const moreCount = gitProjects.length > 3 ? ` +${gitProjects.length - 3} more` : "";
457
+ dispatch(setMessage(`Pushed: ${pushedNames}${moreCount}`));
458
+ }
459
+
460
+ // Refresh after success to reflect latest state
461
+ await onRefresh();
462
+ return;
463
+ }
464
+
465
+ if (input === "P") {
466
+ // Pull all
467
+ const gitProjects = filteredProjects.filter(
468
+ (p) => p.type === "git" && p.status?.hasRemote
469
+ );
470
+
471
+ if (gitProjects.length === 0) {
472
+ dispatch(setMessage("No projects with remotes"));
473
+ return;
474
+ }
475
+
476
+ dispatch(startAction("Pulling"));
477
+
478
+ const result = await batchPull(gitProjects, { concurrency: config.scan.concurrency, onProgress: (current, total) => {
479
+ dispatch(updateProgress(current, total));
480
+ }});
481
+
482
+ dispatch(endAction());
483
+
484
+ // Show detailed pull result
485
+ if (result.failed > 0) {
486
+ dispatch(setMessage(`Pulled ${result.successful}/${result.total} (${result.failed} failed)`));
487
+ } else {
488
+ dispatch(setMessage(`Pulled ${result.successful} projects successfully`));
489
+ }
490
+
491
+ await onRefresh();
492
+ return;
493
+ }
494
+
495
+ if (input === "f") {
496
+ // Fetch all
497
+ const gitProjects = filteredProjects.filter(
498
+ (p) => p.type === "git" && p.status?.hasRemote
499
+ );
500
+
501
+ if (gitProjects.length === 0) {
502
+ dispatch(setMessage("No projects with remotes"));
503
+ return;
504
+ }
505
+
506
+ dispatch(startAction("Fetching"));
507
+
508
+ const result = await batchFetch(gitProjects, { concurrency: config.scan.concurrency, onProgress: (current, total) => {
509
+ dispatch(updateProgress(current, total));
510
+ }});
511
+
512
+ dispatch(endAction());
513
+
514
+ // Show detailed fetch result
515
+ if (result.failed > 0) {
516
+ dispatch(setMessage(`Fetched ${result.successful}/${result.total} (${result.failed} failed)`));
517
+ } else {
518
+ dispatch(setMessage(`Fetched ${result.successful} projects`));
519
+ }
520
+
521
+ await onRefresh();
522
+ return;
523
+ }
524
+
525
+ if (input === "i") {
526
+ // Init git in selected non-git projects
527
+ const nonGitProjects = selectedProjects.filter((p) => p.type === "non-git");
528
+
529
+ if (nonGitProjects.length === 0) {
530
+ dispatch(setMessage("No non-git projects selected"));
531
+ return;
532
+ }
533
+
534
+ dispatch(startAction(`Initializing ${nonGitProjects.length} projects`));
535
+
536
+ let success = 0;
537
+ for (const project of nonGitProjects) {
538
+ const result = await initGitInProject(project.path);
539
+ if (result.success) success++;
540
+ }
541
+
542
+ dispatch(endAction());
543
+ dispatch(
544
+ setMessage(`Initialized ${success}/${nonGitProjects.length} projects`)
545
+ );
546
+
547
+ // Refresh after init
548
+ await onRefresh();
549
+ return;
550
+ }
551
+
552
+ // GitHub operations
553
+ if (input === "c") {
554
+ // Create GitHub repo - show confirmation dialog
555
+ const gitProjects = selectedProjects.filter(
556
+ (p) => p.type === "git" && !p.status?.hasRemote
557
+ );
558
+
559
+ if (gitProjects.length === 0) {
560
+ dispatch(setMessage("No git projects without remotes selected"));
561
+ return;
562
+ }
563
+
564
+ const dialogState: ConfirmDialogState = {
565
+ operation: "create",
566
+ title: "Create GitHub Repos",
567
+ message: "Create GitHub repositories for:",
568
+ items: gitProjects.map((p) => p.name),
569
+ projectPaths: gitProjects.map((p) => p.path),
570
+ showVisibilityToggle: true,
571
+ };
572
+
573
+ dispatch({ type: "SHOW_CONFIRM_DIALOG", payload: dialogState });
574
+ return;
575
+ }
576
+
577
+ if (input === "C") {
578
+ // Setup: init git (if needed) + create GitHub repo + push
579
+ const eligibleProjects = selectedProjects.filter(
580
+ (p) => p.type === "non-git" || (p.type === "git" && !p.status?.hasRemote)
581
+ );
582
+
583
+ if (eligibleProjects.length === 0) {
584
+ dispatch(setMessage("No projects need setup (all have remotes)"));
585
+ return;
586
+ }
587
+
588
+ const dialogState: ConfirmDialogState = {
589
+ operation: "setup",
590
+ title: "Setup Projects",
591
+ message: "Init git (if needed) and create GitHub repos for:",
592
+ items: eligibleProjects.map((p) => `${p.name}${p.type === "non-git" ? " (will init)" : ""}`),
593
+ projectPaths: eligibleProjects.map((p) => p.path),
594
+ showVisibilityToggle: true,
595
+ };
596
+
597
+ dispatch({ type: "SHOW_CONFIRM_DIALOG", payload: dialogState });
598
+ return;
599
+ }
600
+
601
+ if (input === "A") {
602
+ // Archive GitHub repos - show confirmation dialog
603
+ const gitProjects = selectedProjects.filter(
604
+ (p) => p.type === "git" && p.status?.hasRemote
605
+ );
606
+
607
+ if (gitProjects.length === 0) {
608
+ dispatch(setMessage("No git projects with remotes selected"));
609
+ return;
610
+ }
611
+
612
+ const dialogState: ConfirmDialogState = {
613
+ operation: "archive",
614
+ title: "Archive GitHub Repos",
615
+ message: "Archive these repositories on GitHub:",
616
+ items: gitProjects.map((p) => p.name),
617
+ projectPaths: gitProjects.map((p) => p.path),
618
+ showVisibilityToggle: false,
619
+ };
620
+
621
+ dispatch({ type: "SHOW_CONFIRM_DIALOG", payload: dialogState });
622
+ return;
623
+ }
624
+
625
+ // D - Clone GitHub repos
626
+ if (input === "D") {
627
+ const githubOnlyRepos = selectedUnifiedRepos.filter(r => r.source === "github");
628
+
629
+ if (githubOnlyRepos.length === 0) {
630
+ dispatch(setMessage("No GitHub-only repos selected to clone"));
631
+ return;
632
+ }
633
+
634
+ dispatch({
635
+ type: "SHOW_CLONE_DIALOG",
636
+ payload: {
637
+ repos: githubOnlyRepos,
638
+ directories: config.directories,
639
+ selectedDirIndex: 0,
640
+ useSSH: true,
641
+ }
642
+ });
643
+ return;
644
+ }
645
+
646
+ // Custom commands - check if input matches a configured command key
647
+ // This allows executing commands directly from main list without command palette
648
+ if (config.commands.length > 0) {
649
+ const command = findCommandByKey(config.commands, input);
650
+ if (command) {
651
+ await handleCommandExecution(command, selectedUnifiedRepos);
652
+ return;
653
+ }
654
+ }
655
+ });
656
+ }
@@ -0,0 +1,47 @@
1
+ import { useEffect, useCallback } from "react";
2
+ import { useStore } from "../state/store.tsx";
3
+ import {
4
+ setProjects,
5
+ setLoading,
6
+ setError,
7
+ setMessage,
8
+ } from "../state/actions.ts";
9
+ import { scanAllDirectories, sortProjects } from "../scanner/index.ts";
10
+ import type { GitforestConfig } from "../types/index.ts";
11
+ import { errorToString } from "../utils/errors.ts";
12
+
13
+ export function useProjects(config: GitforestConfig) {
14
+ const { state, dispatch } = useStore();
15
+ const { sortBy, sortDirection } = state;
16
+
17
+ const loadProjects = useCallback(async () => {
18
+ dispatch(setLoading(true));
19
+ dispatch(setError(null));
20
+
21
+ try {
22
+ const projects = await scanAllDirectories(config);
23
+ const sorted = sortProjects(projects, sortBy, sortDirection);
24
+ dispatch(setProjects(sorted));
25
+ dispatch(setMessage(`Found ${projects.length} projects`));
26
+ } catch (error) {
27
+ dispatch(setError(errorToString(error)));
28
+ } finally {
29
+ dispatch(setLoading(false));
30
+ }
31
+ }, [config, dispatch, sortBy, sortDirection]);
32
+
33
+ // Load on mount
34
+ useEffect(() => {
35
+ loadProjects();
36
+ }, []);
37
+
38
+ // Re-sort when sort changes
39
+ useEffect(() => {
40
+ if (state.projects.length > 0 && !state.isLoading) {
41
+ const sorted = sortProjects(state.projects, sortBy, sortDirection);
42
+ dispatch(setProjects(sorted));
43
+ }
44
+ }, [sortBy, sortDirection]);
45
+
46
+ return { loadProjects };
47
+ }