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,120 @@
1
+ import { Box, Text } from "ink";
2
+ import type { CommandConfig } from "../types/index.ts";
3
+
4
+ interface HelpOverlayProps {
5
+ commands?: CommandConfig[];
6
+ }
7
+
8
+ const HELP_SECTIONS = [
9
+ {
10
+ title: "Navigation",
11
+ keys: [
12
+ { key: "j / ↓", desc: "Move down" },
13
+ { key: "k / ↑", desc: "Move up" },
14
+ { key: "g", desc: "Go to top" },
15
+ { key: "G", desc: "Go to bottom" },
16
+ ],
17
+ },
18
+ {
19
+ title: "Selection",
20
+ keys: [
21
+ { key: "space", desc: "Toggle selection" },
22
+ { key: "a", desc: "Select all / deselect all" },
23
+ ],
24
+ },
25
+ {
26
+ title: "View Mode",
27
+ keys: [
28
+ { key: "Tab", desc: "Cycle view (local/github/all)" },
29
+ { key: "0", desc: "Show all" },
30
+ { key: "1", desc: "Show dirty" },
31
+ { key: "2", desc: "Show unpushed" },
32
+ { key: "3", desc: "Show no-remote" },
33
+ { key: "4", desc: "Show GitHub-only" },
34
+ { key: "5", desc: "Show local-only" },
35
+ ],
36
+ },
37
+ {
38
+ title: "Git Operations",
39
+ keys: [
40
+ { key: "p", desc: "Push selected" },
41
+ { key: "P", desc: "Pull all repos" },
42
+ { key: "f", desc: "Fetch all remotes" },
43
+ { key: "i", desc: "Init git in selected" },
44
+ ],
45
+ },
46
+ {
47
+ title: "GitHub",
48
+ keys: [
49
+ { key: "D", desc: "Clone GitHub repos" },
50
+ { key: "c", desc: "Create GitHub repo" },
51
+ { key: "C", desc: "Setup (init + create + push)" },
52
+ { key: "A", desc: "Archive GitHub repo" },
53
+ ],
54
+ },
55
+ {
56
+ title: "General",
57
+ keys: [
58
+ { key: "/", desc: "Filter projects" },
59
+ { key: "s", desc: "Cycle sort field" },
60
+ { key: "x", desc: "Command palette" },
61
+ { key: "r", desc: "Refresh" },
62
+ { key: "?", desc: "Toggle help" },
63
+ { key: "Esc", desc: "Cancel / exit mode" },
64
+ { key: "q", desc: "Quit" },
65
+ ],
66
+ },
67
+ ];
68
+
69
+ export function HelpOverlay({ commands = [] }: HelpOverlayProps) {
70
+ // Build sections including custom commands if configured
71
+ const sections = [...HELP_SECTIONS];
72
+
73
+ if (commands.length > 0) {
74
+ sections.push({
75
+ title: "Custom Commands",
76
+ keys: commands.map((cmd) => ({
77
+ key: cmd.key,
78
+ desc: cmd.name,
79
+ })),
80
+ });
81
+ }
82
+
83
+ return (
84
+ <Box
85
+ flexDirection="column"
86
+ borderStyle="round"
87
+ borderColor="cyan"
88
+ paddingX={2}
89
+ paddingY={1}
90
+ >
91
+ <Box marginBottom={1}>
92
+ <Text bold color="cyan">
93
+ Keyboard Shortcuts
94
+ </Text>
95
+ </Box>
96
+
97
+ <Box flexDirection="row" flexWrap="wrap" gap={4}>
98
+ {sections.map((section) => (
99
+ <Box key={section.title} flexDirection="column" width={30}>
100
+ <Text bold underline>
101
+ {section.title}
102
+ </Text>
103
+ {section.keys.map(({ key, desc }) => (
104
+ <Box key={key} gap={1}>
105
+ <Box width={14}>
106
+ <Text color="yellow">{key}</Text>
107
+ </Box>
108
+ <Text>{desc}</Text>
109
+ </Box>
110
+ ))}
111
+ </Box>
112
+ ))}
113
+ </Box>
114
+
115
+ <Box marginTop={1}>
116
+ <Text dimColor>Press any key to close</Text>
117
+ </Box>
118
+ </Box>
119
+ );
120
+ }
@@ -0,0 +1,379 @@
1
+ import { Box, Text, useStdout } from "ink";
2
+ import { StatusBar } from "./StatusBar.tsx";
3
+ import { FilterBar } from "./FilterBar.tsx";
4
+ import { ProjectList } from "./ProjectList.tsx";
5
+ import { HelpOverlay } from "./HelpOverlay.tsx";
6
+ import { ConfirmDialog } from "./ConfirmDialog.tsx";
7
+ import { CloneDialog } from "./CloneDialog.tsx";
8
+ import { RepoDetailModal } from "./RepoDetailModal.tsx";
9
+ import { CommandPalette } from "./CommandPalette.tsx";
10
+ import { useStore, useFilteredUnifiedRepos, useSelectedUnifiedRepos } from "../state/store.tsx";
11
+ import { useConfirmDialogActions } from "../hooks/useConfirmDialogActions.ts";
12
+ import { startAction, endAction, setMessage } from "../state/actions.ts";
13
+ import { executeCommand } from "../operations/commands.ts";
14
+ import { errorToString } from "../utils/errors.ts";
15
+ import { UI } from "../constants.ts";
16
+ import type { GitforestConfig, CommandConfig } from "../types/index.ts";
17
+
18
+ interface LayoutProps {
19
+ config: GitforestConfig;
20
+ onRefresh: () => Promise<void>;
21
+ onClone?: (repos: any[], targetDir: string, useSSH: boolean) => Promise<void>;
22
+ }
23
+
24
+ export function Layout({ config, onRefresh, onClone }: LayoutProps) {
25
+ const { state, dispatch } = useStore();
26
+ const { mode, confirmDialog, cloneDialog } = state;
27
+ const filteredRepos = useFilteredUnifiedRepos();
28
+ const selectedRepos = useSelectedUnifiedRepos();
29
+ const { stdout } = useStdout();
30
+
31
+ const { handleConfirm, handleCancel } = useConfirmDialogActions({
32
+ config,
33
+ onRefresh,
34
+ });
35
+
36
+ // Calculate available height
37
+ const terminalHeight = stdout?.rows ?? UI.DEFAULT_TERMINAL_HEIGHT;
38
+ const listHeight = terminalHeight - UI.LAYOUT_OVERHEAD;
39
+
40
+ // Calculate stats for status bar
41
+ const dirtyCount = filteredRepos.filter((r) => r.local?.status?.isDirty).length;
42
+ const unpushedCount = filteredRepos.filter((r) => r.local?.status?.isAhead).length;
43
+ const localOnlyCount = filteredRepos.filter((r) => r.source === "local").length;
44
+ const githubOnlyCount = filteredRepos.filter((r) => r.source === "github").length;
45
+ const syncedCount = filteredRepos.filter((r) => r.source === "both").length;
46
+
47
+ // Clone dialog handlers
48
+ const handleCloneConfirm = async (targetDir: string, useSSH: boolean) => {
49
+ dispatch({ type: "HIDE_CLONE_DIALOG" });
50
+ if (onClone && cloneDialog) {
51
+ await onClone(cloneDialog.repos, targetDir, useSSH);
52
+ }
53
+ };
54
+
55
+ const handleCloneCancel = () => {
56
+ dispatch({ type: "HIDE_CLONE_DIALOG" });
57
+ };
58
+
59
+ const handleCloneSelectDir = (index: number) => {
60
+ dispatch({ type: "UPDATE_CLONE_DIALOG", payload: { selectedDirIndex: index } });
61
+ };
62
+
63
+ const handleCloneToggleSSH = () => {
64
+ if (cloneDialog) {
65
+ dispatch({ type: "UPDATE_CLONE_DIALOG", payload: { useSSH: !cloneDialog.useSSH } });
66
+ }
67
+ };
68
+
69
+ // Show help overlay
70
+ if (mode === "help") {
71
+ return (
72
+ <Box flexDirection="column" height={terminalHeight}>
73
+ <HelpOverlay commands={config.commands} />
74
+ </Box>
75
+ );
76
+ }
77
+
78
+ // Show command palette
79
+ if (mode === "command-palette") {
80
+ return (
81
+ <Box flexDirection="column" height={terminalHeight} justifyContent="center" alignItems="center">
82
+ <CommandPalette
83
+ commands={config.commands}
84
+ selectedRepos={selectedRepos}
85
+ onClose={() => dispatch({ type: "SET_MODE", payload: "normal" })}
86
+ />
87
+ </Box>
88
+ );
89
+ }
90
+
91
+ // Show confirm dialog
92
+ if (mode === "confirm" && confirmDialog) {
93
+ return (
94
+ <Box flexDirection="column" height={terminalHeight} justifyContent="center" alignItems="center">
95
+ <ConfirmDialog
96
+ title={confirmDialog.title}
97
+ message={confirmDialog.message}
98
+ items={confirmDialog.items}
99
+ showVisibilityToggle={confirmDialog.showVisibilityToggle}
100
+ defaultVisibility={config.github.defaultVisibility}
101
+ onConfirm={handleConfirm}
102
+ onCancel={handleCancel}
103
+ />
104
+ </Box>
105
+ );
106
+ }
107
+
108
+ // Show clone dialog
109
+ if (mode === "clone" && cloneDialog) {
110
+ return (
111
+ <Box flexDirection="column" height={terminalHeight} justifyContent="center" alignItems="center">
112
+ <CloneDialog
113
+ repos={cloneDialog.repos}
114
+ directories={cloneDialog.directories}
115
+ selectedDirIndex={cloneDialog.selectedDirIndex}
116
+ useSSH={cloneDialog.useSSH}
117
+ onConfirm={handleCloneConfirm}
118
+ onCancel={handleCloneCancel}
119
+ onSelectDir={handleCloneSelectDir}
120
+ onToggleSSH={handleCloneToggleSSH}
121
+ />
122
+ </Box>
123
+ );
124
+ }
125
+
126
+ // Show detail modal
127
+ if (mode === "detail" && state.detailModal) {
128
+ const handleDetailClose = () => {
129
+ dispatch({ type: "HIDE_DETAIL_MODAL" });
130
+ };
131
+
132
+ const handleDetailAction = async (action: string) => {
133
+ const repo = state.detailModal?.repo;
134
+ if (!repo) return;
135
+
136
+ switch (action) {
137
+ case "clone": {
138
+ dispatch({
139
+ type: "SHOW_CLONE_DIALOG",
140
+ payload: {
141
+ repos: [repo],
142
+ directories: config.directories,
143
+ selectedDirIndex: 0,
144
+ useSSH: true,
145
+ },
146
+ });
147
+ return;
148
+ }
149
+ case "primary": {
150
+ if (repo.localPath) {
151
+ // Fallthrough to editor logic
152
+ await handleDetailAction("editor");
153
+ } else if (repo.github?.htmlUrl) {
154
+ await handleDetailAction("browser");
155
+ }
156
+ return;
157
+ }
158
+ case "push":
159
+ if (repo.local && repo.local.status?.isAhead) {
160
+ dispatch(startAction("Pushing"));
161
+ // Push logic would be implemented here
162
+ dispatch(endAction());
163
+ }
164
+ break;
165
+ case "pull":
166
+ if (repo.local && repo.local.status?.hasRemote) {
167
+ dispatch(startAction("Pulling"));
168
+ // Pull logic would be implemented here
169
+ dispatch(endAction());
170
+ }
171
+ break;
172
+ case "fetch":
173
+ if (repo.local && repo.local.status?.hasRemote) {
174
+ dispatch(startAction("Fetching"));
175
+ // Fetch logic would be implemented here
176
+ dispatch(endAction());
177
+ }
178
+ break;
179
+ case "browser":
180
+ if (repo.github?.htmlUrl) {
181
+ try {
182
+ Bun.spawn(["open", repo.github.htmlUrl], {
183
+ stdout: "ignore",
184
+ stderr: "ignore",
185
+ });
186
+ } catch (error) {
187
+ dispatch(setMessage(`Failed to open browser: ${errorToString(error)}`));
188
+ }
189
+ } else {
190
+ dispatch(setMessage("No GitHub URL available for this repository"));
191
+ }
192
+ break;
193
+ case "editor":
194
+ if (repo.localPath) {
195
+ // Resolve editor: Project > Global > Env > Default
196
+ let editor = config.editor;
197
+
198
+ // Check for project-specific editor
199
+ if (repo.localPath) {
200
+ const matchedDir = config.directories.find(d =>
201
+ repo.localPath!.startsWith(d.path.replace(/^~/, process.env.HOME || ""))
202
+ );
203
+ if (matchedDir?.editor) {
204
+ editor = matchedDir.editor;
205
+ }
206
+ }
207
+
208
+ if (!editor) {
209
+ editor = process.env.EDITOR || "code";
210
+ }
211
+
212
+ // Split command and args once
213
+ const parts = editor.split(" ");
214
+ const cmd = parts[0]!;
215
+ const args = parts.slice(1);
216
+
217
+ // Check if it's a known terminal editor
218
+ const terminalEditors = ["vim", "nvim", "nano", "vi", "emacs", "hx", "helix"];
219
+ const isTerminal = terminalEditors.includes(cmd);
220
+
221
+ try {
222
+ if (isTerminal) {
223
+ // Suspends Ink's raw mode to allow the editor to take over
224
+ if (process.stdin.setRawMode) {
225
+ process.stdin.setRawMode(false);
226
+ }
227
+
228
+ // Spawn with inherit to take over terminal
229
+ // IMPORTANT: Use split cmd and args
230
+ const proc = Bun.spawn([cmd, ...args, repo.localPath], {
231
+ stdin: "inherit",
232
+ stdout: "inherit",
233
+ stderr: "inherit",
234
+ });
235
+
236
+ await proc.exited;
237
+
238
+ // Resume raw mode
239
+ if (process.stdin.setRawMode) {
240
+ process.stdin.setRawMode(true);
241
+ }
242
+ } else {
243
+ // GUI Editor
244
+
245
+ // Use 'open' command for macOS GUI editors (VS Code, Cursor) specific optimization
246
+ if (process.platform === "darwin" && (cmd === "code" || cmd === "cursor")) {
247
+ const appName = cmd === "code" ? "Visual Studio Code" : "Cursor";
248
+
249
+ const openArgs = ["open", "-a", appName, repo.localPath];
250
+ openArgs.push("--args", "-n"); // Force new window
251
+
252
+ const subprocess = Bun.spawn(openArgs, {
253
+ stdin: "ignore",
254
+ stdout: "ignore",
255
+ stderr: "ignore",
256
+ });
257
+ subprocess.unref();
258
+
259
+ } else {
260
+ // Fallback for other GUI editors
261
+
262
+ // Auto-inject -n for code/cursor if not using 'open' strategy or on other platforms
263
+ if ((cmd === "code" || cmd === "cursor") && !args.includes("-n") && !args.includes("--new-window")) {
264
+ args.push("-n");
265
+ }
266
+
267
+ const subprocess = Bun.spawn([cmd, ...args, repo.localPath], {
268
+ stdin: "ignore",
269
+ stdout: "ignore",
270
+ stderr: "ignore",
271
+ });
272
+ subprocess.unref();
273
+ }
274
+ }
275
+ } catch (error) {
276
+ dispatch(setMessage(`Failed to open editor: ${errorToString(error)}`));
277
+ // Ensure raw mode is back if we failed mid-flight
278
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
279
+ }
280
+ } else {
281
+ dispatch(setMessage("No local path available for this repository"));
282
+ }
283
+ break;
284
+ }
285
+ };
286
+
287
+ const handleDetailScroll = (offset: number) => {
288
+ dispatch({ type: "UPDATE_DETAIL_MODAL", payload: { readmeScrollOffset: offset } });
289
+ };
290
+
291
+ const handleDetailCommand = async (command: CommandConfig) => {
292
+ const repo = state.detailModal?.repo;
293
+ if (!repo) return;
294
+
295
+ const projectPath = repo.localPath || repo.local?.path;
296
+ if (!projectPath) {
297
+ dispatch(setMessage("No local path for this repo"));
298
+ return;
299
+ }
300
+
301
+ dispatch(startAction(`Running: ${command.name}`));
302
+ try {
303
+ const result = await executeCommand(command, projectPath);
304
+ dispatch(endAction());
305
+ if (result.success) {
306
+ const shortOutput = result.output && result.output.length > 50
307
+ ? result.output.slice(0, 50) + "..."
308
+ : result.output || "Done";
309
+ dispatch(setMessage(`${command.name}: ${shortOutput}`));
310
+ } else {
311
+ dispatch(setMessage(`${command.name} failed: ${result.error}`));
312
+ }
313
+ } catch (error) {
314
+ dispatch(endAction());
315
+ dispatch(setMessage(`${command.name} failed: ${error instanceof Error ? error.message : String(error)}`));
316
+ }
317
+ };
318
+
319
+ return (
320
+ <Box flexDirection="column" height={terminalHeight}>
321
+ <RepoDetailModal
322
+ repo={state.detailModal.repo}
323
+ readmeContent={state.detailModal.readmeContent}
324
+ readmeLoading={state.detailModal.readmeLoading}
325
+ readmeError={state.detailModal.readmeError}
326
+ scrollOffset={state.detailModal.readmeScrollOffset}
327
+ commands={config.commands}
328
+ onClose={handleDetailClose}
329
+ onAction={handleDetailAction}
330
+ onScroll={handleDetailScroll}
331
+ onCommand={handleDetailCommand}
332
+ />
333
+ </Box>
334
+ );
335
+ }
336
+
337
+ // Show filter options overlay
338
+ if (mode === "filter-options") {
339
+ return (
340
+ <Box flexDirection="column" height={terminalHeight}>
341
+ <Text>Filter Options Overlay - To be implemented</Text>
342
+ </Box>
343
+ );
344
+ }
345
+
346
+ return (
347
+ <Box flexDirection="column" height={terminalHeight}>
348
+ {/* Header */}
349
+ <Box borderStyle="single" borderBottom borderColor="gray" paddingX={1}>
350
+ <Text bold color="cyan">
351
+ gitforest
352
+ </Text>
353
+ <Text> - Git Repository Manager</Text>
354
+ </Box>
355
+
356
+ {/* Filter bar */}
357
+ <Box paddingX={1} paddingY={0}>
358
+ <FilterBar />
359
+ </Box>
360
+
361
+ {/* Project list */}
362
+ <Box flexGrow={1} flexDirection="column" paddingX={1}>
363
+ <ProjectList height={listHeight} />
364
+ </Box>
365
+
366
+ {/* Status bar */}
367
+ <Box borderStyle="single" borderTop borderColor="gray" paddingX={1}>
368
+ <StatusBar
369
+ projectCount={filteredRepos.length}
370
+ dirtyCount={dirtyCount}
371
+ unpushedCount={unpushedCount}
372
+ localOnlyCount={localOnlyCount}
373
+ githubOnlyCount={githubOnlyCount}
374
+ syncedCount={syncedCount}
375
+ />
376
+ </Box>
377
+ </Box>
378
+ );
379
+ }
@@ -0,0 +1,127 @@
1
+ import { Box, Text } from 'ink';
2
+ import { parseMarkdown, truncateMarkdown, type MarkdownNode } from '../utils/markdown';
3
+
4
+ interface MarkdownRendererProps {
5
+ content: string;
6
+ maxHeight?: number;
7
+ scrollOffset?: number;
8
+ }
9
+
10
+ export function MarkdownRenderer({ content, maxHeight, scrollOffset = 0 }: MarkdownRendererProps): JSX.Element {
11
+ const nodes = parseMarkdown(content);
12
+ const displayNodes = maxHeight ? truncateMarkdown(nodes, maxHeight) : nodes;
13
+
14
+ let lineCount = 0;
15
+
16
+ function renderNode(node: MarkdownNode, indentLevel = 0): JSX.Element[] {
17
+ const indent = ' '.repeat(indentLevel);
18
+
19
+ switch (node.type) {
20
+ case 'heading':
21
+ const headingText = node.content || '';
22
+ if (node.level === 1) {
23
+ return [
24
+ <Text key={`h1-${lineCount++}`} bold color="cyan">
25
+ {headingText}
26
+ </Text>
27
+ ];
28
+ } else if (node.level === 2) {
29
+ return [
30
+ <Text key={`h2-${lineCount++}`} bold color="blue">
31
+ {headingText}
32
+ </Text>
33
+ ];
34
+ } else {
35
+ return [
36
+ <Text key={`h${node.level}-${lineCount++}`} bold>
37
+ {headingText}
38
+ </Text>
39
+ ];
40
+ }
41
+
42
+ case 'paragraph':
43
+ return [
44
+ <Text key={`p-${lineCount++}`}>
45
+ {node.children?.map((child, idx) => renderInline(child, `${lineCount}-${idx}`))}
46
+ </Text>
47
+ ];
48
+
49
+ case 'code':
50
+ return [
51
+ <Text key={`code-${lineCount++}`} inverse>
52
+ {node.content || ''}
53
+ </Text>
54
+ ];
55
+
56
+ case 'codeblock':
57
+ return [
58
+ <Box key={`codeblock-${lineCount++}`} borderStyle="single" borderColor="gray" paddingX={1} marginY={1}>
59
+ <Text>{node.content || ''}</Text>
60
+ </Box>
61
+ ];
62
+
63
+ case 'list':
64
+ return node.children?.map((item, idx) => (
65
+ <Text key={`list-${lineCount++}-${idx}`}>
66
+ {indent}• {item.content || ''}
67
+ </Text>
68
+ )) || [];
69
+
70
+ case 'blockquote':
71
+ return [
72
+ <Text key={`bq-${lineCount++}`} dimColor>
73
+ {indent}│ {node.content || ''}
74
+ </Text>
75
+ ];
76
+
77
+ case 'hr':
78
+ return [
79
+ <Text key={`hr-${lineCount++}`}>
80
+ {'─'.repeat(80)}
81
+ </Text>
82
+ ];
83
+
84
+ default:
85
+ return [];
86
+ }
87
+ }
88
+
89
+ function renderInline(node: MarkdownNode, key: string): JSX.Element {
90
+ switch (node.type) {
91
+ case 'text':
92
+ return <Text key={key}>{node.content || ''}</Text>;
93
+
94
+ case 'bold':
95
+ return <Text key={key} bold>{node.content || ''}</Text>;
96
+
97
+ case 'italic':
98
+ return <Text key={key} dimColor>{node.content || ''}</Text>;
99
+
100
+ case 'code':
101
+ return <Text key={key} inverse>{node.content || ''}</Text>;
102
+
103
+ case 'link':
104
+ return <Text key={key} color="cyan" underline>{node.content || ''}</Text>;
105
+
106
+ default:
107
+ return <Text key={key}>{node.content || ''}</Text>;
108
+ }
109
+ }
110
+
111
+ // Render all nodes
112
+ const allLines: JSX.Element[] = [];
113
+ for (const node of displayNodes) {
114
+ allLines.push(...renderNode(node));
115
+ }
116
+
117
+ // Apply scroll offset
118
+ const visibleLines = scrollOffset > 0 ? allLines.slice(scrollOffset) : allLines;
119
+
120
+ return (
121
+ <Box flexDirection="column">
122
+ {visibleLines.map((line, idx) => (
123
+ <Box key={idx}>{line}</Box>
124
+ ))}
125
+ </Box>
126
+ );
127
+ }
@@ -0,0 +1,53 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+
4
+ interface ProgressBarProps {
5
+ label: string;
6
+ current: number;
7
+ total: number;
8
+ width?: number;
9
+ }
10
+
11
+ /**
12
+ * Render a progress bar with label and percentage
13
+ */
14
+ export function ProgressBar({ label, current, total, width = 20 }: ProgressBarProps) {
15
+ const percent = total > 0 ? Math.round((current / total) * 100) : 0;
16
+ const filled = Math.round((percent / 100) * width);
17
+ const empty = width - filled;
18
+
19
+ const bar = "█".repeat(filled) + "░".repeat(empty);
20
+
21
+ return (
22
+ <Box gap={1}>
23
+ <Text color="yellow">{label}</Text>
24
+ <Text color="cyan">[{bar}]</Text>
25
+ <Text dimColor>
26
+ {current}/{total} ({percent}%)
27
+ </Text>
28
+ </Box>
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Simple spinner for indeterminate progress
34
+ */
35
+ export function Spinner({ label }: { label: string }) {
36
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
37
+ const [frameIndex, setFrameIndex] = React.useState(0);
38
+
39
+ React.useEffect(() => {
40
+ const timer = setInterval(() => {
41
+ setFrameIndex((prev) => (prev + 1) % frames.length);
42
+ }, 80);
43
+
44
+ return () => clearInterval(timer);
45
+ }, []);
46
+
47
+ return (
48
+ <Box gap={1}>
49
+ <Text color="yellow">{frames[frameIndex]}</Text>
50
+ <Text color="yellow">{label}</Text>
51
+ </Box>
52
+ );
53
+ }