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,143 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import type { Project } from "../types/index.ts";
4
+
5
+ interface ProjectItemProps {
6
+ project: Project;
7
+ isSelected: boolean;
8
+ isCursor: boolean;
9
+ }
10
+
11
+ /**
12
+ * Get status icon for a project
13
+ */
14
+ function getStatusIcon(project: Project): { icon: string; color: string } {
15
+ if (project.type === "non-git") {
16
+ return { icon: "-", color: "gray" };
17
+ }
18
+
19
+ if (project.type === "git-submodule") {
20
+ return { icon: "○", color: "magenta" };
21
+ }
22
+
23
+ if (!project.status) {
24
+ return { icon: "?", color: "gray" };
25
+ }
26
+
27
+ if (project.status.isDirty) {
28
+ return { icon: "●", color: "yellow" };
29
+ }
30
+
31
+ return { icon: "✓", color: "green" };
32
+ }
33
+
34
+ /**
35
+ * Format sync status badges
36
+ */
37
+ function getSyncBadges(project: Project): React.ReactNode[] {
38
+ const badges: React.ReactNode[] = [];
39
+
40
+ if (!project.status) return badges;
41
+
42
+ if (project.status.isAhead && project.status.unpushedCommits > 0) {
43
+ badges.push(
44
+ <Text key="ahead" color="blue">
45
+ ↑{project.status.unpushedCommits}
46
+ </Text>
47
+ );
48
+ }
49
+
50
+ if (project.status.isBehind && project.status.unpulledCommits > 0) {
51
+ badges.push(
52
+ <Text key="behind" color="magenta">
53
+ ↓{project.status.unpulledCommits}
54
+ </Text>
55
+ );
56
+ }
57
+
58
+ if (!project.status.hasRemote && project.type === "git") {
59
+ badges.push(
60
+ <Text key="noremote" color="gray">
61
+ no-remote
62
+ </Text>
63
+ );
64
+ }
65
+
66
+ return badges;
67
+ }
68
+
69
+ /**
70
+ * Format the last activity date
71
+ */
72
+ function formatLastActivity(date: Date | null | undefined): string {
73
+ if (!date) return "";
74
+
75
+ const now = new Date();
76
+ const diff = now.getTime() - date.getTime();
77
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
78
+
79
+ if (days === 0) return "today";
80
+ if (days === 1) return "yesterday";
81
+ if (days < 7) return `${days}d ago`;
82
+ if (days < 30) return `${Math.floor(days / 7)}w ago`;
83
+ if (days < 365) return `${Math.floor(days / 30)}mo ago`;
84
+ return `${Math.floor(days / 365)}y ago`;
85
+ }
86
+
87
+ export function ProjectItem({ project, isSelected, isCursor }: ProjectItemProps) {
88
+ const { icon, color } = getStatusIcon(project);
89
+ const syncBadges = getSyncBadges(project);
90
+ const lastActivity = formatLastActivity(project.status?.lastLocalCommit);
91
+
92
+ const bgColor = isCursor ? "blue" : undefined;
93
+ const textColor = isCursor ? "white" : undefined;
94
+
95
+ return (
96
+ <Box>
97
+ {/* Cursor indicator */}
98
+ <Box width={2}>
99
+ <Text color={textColor} backgroundColor={bgColor}>{isCursor ? ">" : " "}</Text>
100
+ </Box>
101
+
102
+ {/* Selection checkbox */}
103
+ <Box width={4}>
104
+ <Text color={isSelected ? "green" : textColor} backgroundColor={bgColor}>
105
+ {isSelected ? "[x]" : "[ ]"}
106
+ </Text>
107
+ </Box>
108
+
109
+ {/* Status icon */}
110
+ <Box width={3}>
111
+ <Text color={isCursor ? textColor : color} backgroundColor={bgColor}>{icon}</Text>
112
+ </Box>
113
+
114
+ {/* Project name */}
115
+ <Box flexGrow={1}>
116
+ <Text color={textColor} backgroundColor={bgColor} bold={isCursor}>
117
+ {project.name}
118
+ </Text>
119
+ {project.status?.currentBranch && (
120
+ <Text color={isCursor ? textColor : "cyan"} backgroundColor={bgColor}> ⎇ {project.status.currentBranch}</Text>
121
+ )}
122
+ {project.type === "git-submodule" && (
123
+ <Text color={isCursor ? textColor : "gray"} backgroundColor={bgColor}> (sub)</Text>
124
+ )}
125
+ {project.projectMarker && project.type === "non-git" && (
126
+ <Text color={isCursor ? textColor : "gray"} backgroundColor={bgColor}> [{project.projectMarker}]</Text>
127
+ )}
128
+ </Box>
129
+
130
+ {/* Sync badges */}
131
+ <Box width={12} gap={1}>
132
+ {syncBadges.map((badge, i) => (
133
+ <Text key={i} backgroundColor={bgColor}>{badge}</Text>
134
+ ))}
135
+ </Box>
136
+
137
+ {/* Last activity */}
138
+ <Box width={12} justifyContent="flex-end">
139
+ <Text color={isCursor ? textColor : "gray"} backgroundColor={bgColor}>{lastActivity}</Text>
140
+ </Box>
141
+ </Box>
142
+ );
143
+ }
@@ -0,0 +1,90 @@
1
+ import { Box, Text } from "ink";
2
+ import { Spinner } from "@inkjs/ui";
3
+ import { UnifiedProjectItem } from "./UnifiedProjectItem.tsx";
4
+ import { ColumnHeader, ColumnSeparator } from "./ColumnHeader.tsx";
5
+ import { useStore, useFilteredUnifiedRepos } from "../state/store.tsx";
6
+
7
+ interface ProjectListProps {
8
+ height?: number;
9
+ }
10
+
11
+ export function ProjectList({ height = 20 }: ProjectListProps) {
12
+ const { state } = useStore();
13
+ const { cursorIndex, selectedIndices, isLoading, sortBy, sortDirection } = state;
14
+ const filteredRepos = useFilteredUnifiedRepos();
15
+
16
+ if (isLoading) {
17
+ return (
18
+ <Box flexDirection="column" alignItems="center" justifyContent="center" height={height}>
19
+ <Spinner label="Scanning projects..." />
20
+ </Box>
21
+ );
22
+ }
23
+
24
+ if (filteredRepos.length === 0) {
25
+ return (
26
+ <Box flexDirection="column">
27
+ {/* Always show headers */}
28
+ <ColumnHeader sortBy={sortBy} sortDirection={sortDirection} />
29
+ <ColumnSeparator />
30
+ <Box flexDirection="column" alignItems="center" justifyContent="center" height={height - 2}>
31
+ <Text color="gray">No repositories found</Text>
32
+ {state.filterText && (
33
+ <Text color="gray">Try a different filter</Text>
34
+ )}
35
+ {state.viewMode === "github" && !state.githubRepos.length && (
36
+ <>
37
+ {state.githubError ? (
38
+ <Text color="red">GitHub error: {state.githubError}</Text>
39
+ ) : (
40
+ <Text color="yellow">Set GITHUB_TOKEN to see GitHub repos</Text>
41
+ )}
42
+ </>
43
+ )}
44
+ </Box>
45
+ </Box>
46
+ );
47
+ }
48
+
49
+ // Reserve space for header (2 lines: header + separator)
50
+ const listHeight = height - 2;
51
+
52
+ // Calculate visible range with scroll offset
53
+ const visibleCount = Math.min(listHeight, filteredRepos.length);
54
+ let startIndex = Math.max(0, cursorIndex - Math.floor(visibleCount / 2));
55
+
56
+ // Adjust if we're near the end
57
+ if (startIndex + visibleCount > filteredRepos.length) {
58
+ startIndex = Math.max(0, filteredRepos.length - visibleCount);
59
+ }
60
+
61
+ const visibleRepos = filteredRepos.slice(startIndex, startIndex + visibleCount);
62
+
63
+ return (
64
+ <Box flexDirection="column">
65
+ {/* Persistent column headers */}
66
+ <ColumnHeader sortBy={sortBy} sortDirection={sortDirection} />
67
+ <ColumnSeparator />
68
+
69
+ {/* Repository list */}
70
+ {visibleRepos.map((repo, idx) => {
71
+ const realIndex = startIndex + idx;
72
+ return (
73
+ <UnifiedProjectItem
74
+ key={repo.id}
75
+ repo={repo}
76
+ isSelected={selectedIndices.has(realIndex)}
77
+ isCursor={realIndex === cursorIndex}
78
+ />
79
+ );
80
+ })}
81
+
82
+ {/* Scroll indicator at bottom */}
83
+ {startIndex + visibleCount < filteredRepos.length && (
84
+ <Box>
85
+ <Text color="gray"> ↓ {filteredRepos.length - startIndex - visibleCount} more below</Text>
86
+ </Box>
87
+ )}
88
+ </Box>
89
+ );
90
+ }
@@ -0,0 +1,367 @@
1
+ import { useState, useEffect } from "react";
2
+ import { Box, Text, useInput, useStdout } from "ink";
3
+ import type { UnifiedRepo, CommandConfig } from "../types/index.ts";
4
+ import { MarkdownRenderer } from "./MarkdownRenderer.tsx";
5
+
6
+ export interface RepoDetailModalProps {
7
+ repo: UnifiedRepo;
8
+ readmeContent: string | null;
9
+ readmeLoading: boolean;
10
+ readmeError: string | null;
11
+ scrollOffset: number;
12
+ commands?: CommandConfig[];
13
+ onClose: () => void;
14
+ onAction: (action: string) => void;
15
+ onScroll: (offset: number) => void;
16
+ onCommand?: (command: CommandConfig) => void;
17
+ }
18
+
19
+ function formatSize(bytes: number): string {
20
+ if (bytes < 1024) return `${bytes} B`;
21
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
22
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
23
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
24
+ }
25
+
26
+ export function RepoDetailModal({
27
+ repo,
28
+ readmeContent,
29
+ readmeLoading,
30
+ readmeError,
31
+ scrollOffset,
32
+ commands = [],
33
+ onClose,
34
+ onAction,
35
+ onScroll,
36
+ onCommand,
37
+ }: RepoDetailModalProps) {
38
+ const { stdout } = useStdout();
39
+ const [dimensions, setDimensions] = useState({ width: 80, height: 24 });
40
+ const [selectedSection, setSelectedSection] = useState<"actions" | "readme">("actions");
41
+
42
+ useEffect(() => {
43
+ if (stdout) {
44
+ const updateDimensions = () => {
45
+ setDimensions({
46
+ width: stdout.columns || 80,
47
+ height: stdout.rows || 24,
48
+ });
49
+ };
50
+
51
+ updateDimensions();
52
+ stdout.on("resize", updateDimensions);
53
+ return () => {
54
+ stdout.off("resize", updateDimensions);
55
+ };
56
+ }
57
+ }, [stdout]);
58
+
59
+ useInput((input, key) => {
60
+ if (key.escape) {
61
+ onClose();
62
+ return;
63
+ }
64
+
65
+ if (input === "j" || key.downArrow) {
66
+ if (selectedSection === "readme" && readmeContent) {
67
+ onScroll(scrollOffset + 1);
68
+ }
69
+ return;
70
+ }
71
+
72
+ if (input === "k" || key.upArrow) {
73
+ if (selectedSection === "readme" && readmeContent) {
74
+ onScroll(Math.max(0, scrollOffset - 1));
75
+ }
76
+ return;
77
+ }
78
+
79
+ if (input === "\t" || key.tab) {
80
+ setSelectedSection(selectedSection === "actions" ? "readme" : "actions");
81
+ return;
82
+ }
83
+
84
+ if (selectedSection === "actions") {
85
+ if (key.return) {
86
+ if (canClone) {
87
+ onAction("clone");
88
+ } else {
89
+ onAction("primary");
90
+ }
91
+ return;
92
+ }
93
+
94
+ switch (input) {
95
+ case "p":
96
+ if (hasLocal) onAction("push");
97
+ break;
98
+ case "P":
99
+ if (hasLocal) onAction("pull");
100
+ break;
101
+ case "f":
102
+ if (hasLocal) onAction("fetch");
103
+ break;
104
+ case "o":
105
+ onAction("browser");
106
+ break;
107
+ case "d":
108
+ if (hasLocal) onAction("editor");
109
+ break;
110
+ default:
111
+ // Check if it's a custom command key
112
+ if (onCommand && commands.length > 0) {
113
+ const command = commands.find((c) => c.key === input);
114
+ if (command) {
115
+ onCommand(command);
116
+ }
117
+ }
118
+ break;
119
+ }
120
+ }
121
+ });
122
+
123
+ const maxWidth = Math.min(100, dimensions.width - 4);
124
+ const maxHeight = dimensions.height - 4;
125
+ const readmeHeight = Math.max(10, maxHeight - 20);
126
+
127
+ const githubInfo = repo.github;
128
+ const localInfo = repo.local;
129
+ const status = localInfo?.status;
130
+
131
+ // Derive local path from multiple sources for robustness
132
+ const localPath = repo.localPath || localInfo?.path;
133
+
134
+ // Determine if we have local and remote presence
135
+ const hasLocal = !!(localPath || repo.source === "local" || repo.source === "both");
136
+ const hasRemote = !!(githubInfo?.htmlUrl || repo.source === "github" || repo.source === "both");
137
+
138
+ // Action availability based on state
139
+ const canClone = !hasLocal && hasRemote;
140
+ const canPush = hasLocal && !!(status?.isAhead && status.unpushedCommits > 0);
141
+ const canPull = hasLocal && !!(status?.hasRemote && status.unpulledCommits > 0);
142
+ const canFetch = hasLocal && !!status?.hasRemote;
143
+ const canOpenBrowser = hasRemote && !!githubInfo?.htmlUrl;
144
+ const canOpenEditor = hasLocal && !!localPath;
145
+
146
+
147
+ return (
148
+ <Box
149
+ flexDirection="column"
150
+ borderStyle="round"
151
+ borderColor="cyan"
152
+ paddingX={1}
153
+ paddingY={1}
154
+ width={maxWidth}
155
+ height={maxHeight}
156
+ >
157
+ {/* Header */}
158
+ <Box justifyContent="center" marginBottom={1}>
159
+ <Text bold color="cyan">
160
+ {githubInfo?.fullName || repo.name}
161
+ </Text>
162
+ </Box>
163
+
164
+ {/* Repository Info */}
165
+ <Box flexDirection="column" marginBottom={1}>
166
+ <Box justifyContent="space-between" flexDirection="row" gap={2}>
167
+ <Box flexDirection="column" gap={1}>
168
+ {githubInfo && (
169
+ <Box gap={1}>
170
+ <Text color="cyan">☁ GitHub</Text>
171
+ <Text color="magenta">{githubInfo.fullName}</Text>
172
+ </Box>
173
+ )}
174
+ {localInfo && (
175
+ <Box gap={1}>
176
+ <Text color="green">💻 Local</Text>
177
+ <Text color="blue">{localInfo.path}</Text>
178
+ </Box>
179
+ )}
180
+ </Box>
181
+ <Box flexDirection="column" gap={1} alignItems="flex-end">
182
+ {githubInfo && (
183
+ <Box gap={1}>
184
+ {githubInfo.isPrivate && <Text color="yellow">🔒 Private</Text>}
185
+ <Text color="yellow">★{githubInfo.stargazersCount || 0}</Text>
186
+ <Text color="yellow">🍴{githubInfo.forksCount || 0}</Text>
187
+ </Box>
188
+ )}
189
+ {status && (
190
+ <Box gap={1}>
191
+ <Text color="magenta">⎇ {status.currentBranch}</Text>
192
+ {localInfo?.type === "git-submodule" && (
193
+ <Text color="yellow">(submodule)</Text>
194
+ )}
195
+ {githubInfo && (
196
+ <Text color="gray">{formatSize(githubInfo.size)}</Text>
197
+ )}
198
+ </Box>
199
+ )}
200
+ </Box>
201
+ </Box>
202
+ </Box>
203
+
204
+ {/* Status Section */}
205
+ {status && (
206
+ <Box
207
+ borderStyle="round"
208
+ borderColor="gray"
209
+ paddingX={1}
210
+ paddingY={1}
211
+ marginBottom={1}
212
+ flexDirection="column"
213
+ gap={1}
214
+ >
215
+ <Box gap={2} flexWrap="wrap">
216
+ <Text color={status.isDirty ? "red" : "green"}>
217
+ {status.isDirty ? "● Dirty" : "○ Clean"}
218
+ </Text>
219
+ <Text color="yellow">{status.modifiedCount} modified</Text>
220
+ {status.stagedCount > 0 && (
221
+ <Text color="green">{status.stagedCount} staged</Text>
222
+ )}
223
+ {status.untrackedCount > 0 && (
224
+ <Text color="gray">{status.untrackedCount} untracked</Text>
225
+ )}
226
+ </Box>
227
+ <Box gap={2} flexWrap="wrap">
228
+ <Text color={status.isAhead ? "yellow" : "gray"}>
229
+ ↑ {status.unpushedCommits} to push
230
+ </Text>
231
+ <Text color={status.isBehind ? "yellow" : "gray"}>
232
+ ↓ {status.unpulledCommits} to pull
233
+ </Text>
234
+ </Box>
235
+ </Box>
236
+ )}
237
+
238
+ {/* Actions Section */}
239
+ <Box
240
+ borderStyle="round"
241
+ borderColor={selectedSection === "actions" ? "cyan" : "gray"}
242
+ paddingX={1}
243
+ paddingY={1}
244
+ marginBottom={1}
245
+ flexDirection="column"
246
+ gap={1}
247
+ >
248
+ <Box gap={3} flexWrap="wrap">
249
+ <Box gap={1}>
250
+ <Text color={canClone ? "yellow" : "gray"}>[Enter]</Text>
251
+ <Text dimColor={!canClone}>Clone</Text>
252
+ {canClone && <Text dimColor> (picker)</Text>}
253
+ </Box>
254
+ <Box gap={1}>
255
+ <Text color={canPush ? "yellow" : "gray"}>[p]</Text>
256
+ <Text dimColor={!canPush}>Push</Text>
257
+ </Box>
258
+ <Box gap={1}>
259
+ <Text color={canPull ? "yellow" : "gray"}>[P]</Text>
260
+ <Text dimColor={!canPull}>Pull</Text>
261
+ </Box>
262
+ <Box gap={1}>
263
+ <Text color={canFetch ? "yellow" : "gray"}>[f]</Text>
264
+ <Text dimColor={!canFetch}>Fetch</Text>
265
+ </Box>
266
+ <Box gap={1}>
267
+ <Text color={canOpenBrowser ? "yellow" : "gray"}>[o]</Text>
268
+ <Text dimColor={!canOpenBrowser}>Open Browser</Text>
269
+ </Box>
270
+ <Box gap={1}>
271
+ <Text color={canOpenEditor ? "yellow" : "gray"}>[d]</Text>
272
+ <Text dimColor={!canOpenEditor}>Editor</Text>
273
+ </Box>
274
+ </Box>
275
+
276
+ {!hasLocal && (
277
+ <Box>
278
+ <Text dimColor>Clone to enable push, pull, fetch, and editor.</Text>
279
+ </Box>
280
+ )}
281
+ {commands.length > 0 && (
282
+ <Box gap={1} flexWrap="wrap">
283
+ {commands.slice(0, 5).map((cmd) => (
284
+ <Box key={cmd.key} gap={0}>
285
+ <Text color="magenta">[{cmd.key}]</Text>
286
+ <Text>{cmd.name}</Text>
287
+ <Text> </Text>
288
+ </Box>
289
+ ))}
290
+ {commands.length > 5 && (
291
+ <Text dimColor>+{commands.length - 5} more</Text>
292
+ )}
293
+ </Box>
294
+ )}
295
+ </Box>
296
+
297
+ {/* README Section */}
298
+ <Box
299
+ flexDirection="column"
300
+ flexGrow={1}
301
+ borderStyle="round"
302
+ borderColor={selectedSection === "readme" ? "cyan" : "gray"}
303
+ paddingX={1}
304
+ paddingY={1}
305
+ minHeight={readmeHeight}
306
+ >
307
+ <Box marginBottom={1}>
308
+ <Text bold color="cyan">
309
+ README.md
310
+ </Text>
311
+ </Box>
312
+
313
+ <Box flexDirection="column" flexGrow={1}>
314
+ {readmeLoading && (
315
+ <Text color="yellow">Loading README...</Text>
316
+ )}
317
+
318
+ {readmeError && (
319
+ <Text color="red">Error: {readmeError}</Text>
320
+ )}
321
+
322
+ {readmeContent && !readmeLoading && !readmeError && (
323
+ <MarkdownRenderer
324
+ content={readmeContent}
325
+ maxHeight={readmeHeight - 2}
326
+ scrollOffset={scrollOffset}
327
+ />
328
+ )}
329
+
330
+ {!readmeContent && !readmeLoading && !readmeError && (
331
+ <Text dimColor>No README available</Text>
332
+ )}
333
+ </Box>
334
+ </Box>
335
+
336
+ {/* Footer */}
337
+ <Box justifyContent="space-between" marginTop={1}>
338
+ <Box gap={1}>
339
+ {githubInfo?.topics && githubInfo.topics.length > 0 && (
340
+ <>
341
+ <Text>Topics:</Text>
342
+ <Text color="blue">{githubInfo.topics.join(", ")}</Text>
343
+ </>
344
+ )}
345
+ {githubInfo?.license && (
346
+ <>
347
+ <Text>License:</Text>
348
+ <Text color="blue">{githubInfo.license}</Text>
349
+ </>
350
+ )}
351
+ {githubInfo?.language && (
352
+ <>
353
+ <Text>Language:</Text>
354
+ <Text color="blue">{githubInfo.language}</Text>
355
+ </>
356
+ )}
357
+ </Box>
358
+
359
+ <Box gap={2}>
360
+ <Text dimColor>[Esc] Close</Text>
361
+ <Text dimColor>[j/k] Scroll</Text>
362
+ <Text dimColor>[Tab] Next section</Text>
363
+ </Box>
364
+ </Box>
365
+ </Box>
366
+ );
367
+ }