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,188 @@
1
+ import React, { useState, useEffect, useRef } from "react";
2
+ import { Box, Text } from "ink";
3
+ import { useStore } from "../state/store.tsx";
4
+ import { ProgressBar } from "./ProgressBar.tsx";
5
+ import { palette } from "../ui/theme.ts";
6
+ import type { QuickFilter } from "../types/index.ts";
7
+
8
+ // Animated spinner for refresh indicator
9
+ function RefreshSpinner() {
10
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
11
+ const [frameIndex, setFrameIndex] = useState(0);
12
+
13
+ useEffect(() => {
14
+ const timer = setInterval(() => {
15
+ setFrameIndex((prev) => (prev + 1) % frames.length);
16
+ }, 80);
17
+ return () => clearInterval(timer);
18
+ }, []);
19
+
20
+ return <Text color="blue">{frames[frameIndex]} Syncing</Text>;
21
+ }
22
+
23
+ interface StatusBarProps {
24
+ projectCount: number;
25
+ dirtyCount: number;
26
+ unpushedCount: number;
27
+ localOnlyCount?: number;
28
+ githubOnlyCount?: number;
29
+ syncedCount?: number;
30
+ }
31
+
32
+ const QUICK_FILTER_LABELS: Record<QuickFilter, string | null> = {
33
+ all: null,
34
+ dirty: "dirty",
35
+ unpushed: "unpushed",
36
+ "no-remote": "no-remote",
37
+ "github-only": "github-only",
38
+ "local-only": "local-only",
39
+ private: "private",
40
+ public: "public",
41
+ archived: "archived",
42
+ forks: "forks",
43
+ };
44
+
45
+ export function StatusBar({
46
+ projectCount,
47
+ dirtyCount,
48
+ unpushedCount,
49
+ localOnlyCount = 0,
50
+ githubOnlyCount = 0,
51
+ syncedCount = 0,
52
+ }: StatusBarProps) {
53
+ const { state } = useStore();
54
+ const {
55
+ actionInProgress,
56
+ actionProgress,
57
+ message,
58
+ error,
59
+ sortBy,
60
+ sortDirection,
61
+ quickFilter,
62
+ viewMode,
63
+ isLoadingGitHub,
64
+ githubError,
65
+ isRefreshing,
66
+ isLoading,
67
+ } = state;
68
+
69
+ // Debounce + hysteresis for spinner: avoid flicker but keep visible for real work
70
+ const [showRefreshSpinner, setShowRefreshSpinner] = useState(false);
71
+ const visibleSinceRef = useRef<number | null>(null);
72
+ const timersRef = useRef<{ show?: ReturnType<typeof setTimeout>; hide?: ReturnType<typeof setTimeout> }>({});
73
+
74
+ useEffect(() => {
75
+ const clearTimers = () => {
76
+ if (timersRef.current.show) clearTimeout(timersRef.current.show);
77
+ if (timersRef.current.hide) clearTimeout(timersRef.current.hide);
78
+ timersRef.current = {};
79
+ };
80
+
81
+ const spinnerActive = actionInProgress || isLoadingGitHub || isRefreshing;
82
+
83
+ // Immediate show for explicit actions; delayed show for background loading
84
+ if (spinnerActive) {
85
+ clearTimers();
86
+ if (actionInProgress) {
87
+ if (!showRefreshSpinner) visibleSinceRef.current = Date.now();
88
+ setShowRefreshSpinner(true);
89
+ return;
90
+ }
91
+ timersRef.current.show = setTimeout(() => {
92
+ if (!showRefreshSpinner) visibleSinceRef.current = Date.now();
93
+ setShowRefreshSpinner(true);
94
+ }, 400);
95
+ return () => clearTimers();
96
+ }
97
+
98
+ // spinnerActive is false: hide after minimum visible time (500ms) to prevent flicker
99
+ clearTimers();
100
+ const elapsed = visibleSinceRef.current ? Date.now() - visibleSinceRef.current : 0;
101
+ const remaining = elapsed < 500 ? 500 - elapsed : 0;
102
+ timersRef.current.hide = setTimeout(() => {
103
+ setShowRefreshSpinner(false);
104
+ visibleSinceRef.current = null;
105
+ }, remaining);
106
+
107
+ return () => clearTimers();
108
+ }, [actionInProgress, isLoadingGitHub, isRefreshing, showRefreshSpinner]);
109
+
110
+ const quickFilterLabel = QUICK_FILTER_LABELS[quickFilter];
111
+ const showLocalStats = viewMode === "local" || viewMode === "combined";
112
+ const showRemoteStats = viewMode === "github" || viewMode === "combined";
113
+
114
+ // Build static segments with separators for stability
115
+ const segments: { text: string; color?: string }[] = [];
116
+ segments.push({ text: `${projectCount} repos`, color: palette.text.muted });
117
+ if (showLocalStats && syncedCount > 0) {
118
+ segments.push({ text: "•", color: palette.text.muted });
119
+ segments.push({ text: `${syncedCount} synced`, color: palette.text.info });
120
+ }
121
+ if (showLocalStats && localOnlyCount > 0) {
122
+ segments.push({ text: "•", color: palette.text.muted });
123
+ segments.push({ text: `${localOnlyCount} local`, color: palette.text.success });
124
+ }
125
+ if (showRemoteStats && githubOnlyCount > 0) {
126
+ segments.push({ text: "•", color: palette.text.muted });
127
+ segments.push({ text: `${githubOnlyCount} remote`, color: palette.text.muted });
128
+ }
129
+ if (showLocalStats && dirtyCount > 0) {
130
+ segments.push({ text: "•", color: palette.text.muted });
131
+ segments.push({ text: `${dirtyCount} dirty`, color: palette.text.warning });
132
+ }
133
+ if (showLocalStats && unpushedCount > 0) {
134
+ segments.push({ text: "•", color: palette.text.muted });
135
+ segments.push({ text: `${unpushedCount} unpushed`, color: palette.badge.push.bg });
136
+ }
137
+ if (quickFilterLabel) {
138
+ segments.push({ text: "•", color: palette.text.muted });
139
+ segments.push({ text: `[${quickFilterLabel}]`, color: palette.text.warning });
140
+ }
141
+
142
+ // Show action in progress with progress bar
143
+ if (actionInProgress) {
144
+ if (actionProgress) {
145
+ return (
146
+ <ProgressBar
147
+ label={actionInProgress}
148
+ current={actionProgress.current}
149
+ total={actionProgress.total}
150
+ />
151
+ );
152
+ }
153
+ return (
154
+ <Box paddingX={1}>
155
+ <Text color={palette.text.warning}>{actionInProgress}...</Text>
156
+ </Box>
157
+ );
158
+ }
159
+
160
+ const sortArrow = sortDirection === "desc" ? "↓" : "↑";
161
+
162
+ return (
163
+ <Box flexDirection="column" paddingX={1} width="100%">
164
+ <Box gap={2} alignItems="center" justifyContent="space-between" width="100%" flexWrap="wrap">
165
+ <Box gap={1} alignItems="center" flexWrap="wrap">
166
+ {error && <Text color={palette.text.danger}>⚠ {error}</Text>}
167
+ {message && !error && <Text color={palette.text.success}>{message}</Text>}
168
+ {isLoadingGitHub && <Text color={palette.text.warning}>⟳ GitHub</Text>}
169
+ {showRefreshSpinner && <RefreshSpinner />}
170
+ {githubError && !isLoadingGitHub && <Text color={palette.text.danger}>⚠ {githubError}</Text>}
171
+ {segments.map((seg, idx) => (
172
+ <Text key={idx} color={seg.color}>
173
+ {seg.text}
174
+ </Text>
175
+ ))}
176
+ </Box>
177
+
178
+ <Text dimColor>
179
+ sort: {sortBy} {sortArrow}
180
+ </Text>
181
+ </Box>
182
+
183
+ <Box justifyContent="flex-end" width="100%">
184
+ <Text dimColor>Tab:view D:clone ?:help</Text>
185
+ </Box>
186
+ </Box>
187
+ );
188
+ }
@@ -0,0 +1,436 @@
1
+ import { Box, Text } from "ink";
2
+ import type { UnifiedRepo } from "../types/index.ts";
3
+ import { palette } from "../ui/theme.ts";
4
+
5
+ interface UnifiedProjectItemProps {
6
+ repo: UnifiedRepo;
7
+ isSelected: boolean;
8
+ isCursor: boolean;
9
+ }
10
+
11
+ // Nerd Font icons using Unicode escape sequences
12
+ // Reference: https://github.com/ryanoasis/nerd-fonts/wiki/Glyph-Sets-and-Code-Points
13
+ export const NF = {
14
+ // Devicons (e700-e8ef)
15
+ github: '\ue709', // nf-dev-github_badge
16
+ git_branch: '\ue725', // nf-dev-git_branch
17
+ python: '\ue73c', // nf-dev-python
18
+ rust: '\ue7a8', // nf-dev-rust
19
+ ruby: '\ue791', // nf-dev-ruby
20
+ java: '\ue738', // nf-dev-java
21
+ html5: '\ue736', // nf-dev-html5
22
+ css3: '\ue749', // nf-dev-css3
23
+ terminal: '\ue795', // nf-dev-terminal
24
+ php: '\ue73d', // nf-dev-php
25
+ swift: '\ue755', // nf-dev-swift
26
+ scala: '\ue737', // nf-dev-scala
27
+ haskell: '\ue777', // nf-dev-haskell
28
+ erlang: '\ue7b1', // nf-dev-erlang
29
+ clojure: '\ue768', // nf-dev-clojure
30
+ perl: '\ue769', // nf-dev-perl
31
+ docker: '\ue7b0', // nf-dev-docker
32
+ markdown: '\ue73e', // nf-dev-markdown
33
+ vim: '\ue7c5', // nf-dev-vim
34
+ react: '\ue7ba', // nf-dev-react
35
+
36
+ // Font Awesome (f000-f2ff)
37
+ star: '\uf005', // nf-fa-star
38
+ star_empty: '\uf006', // nf-fa-star_o
39
+ lock: '\uf023', // nf-fa-lock
40
+ unlock: '\uf09c', // nf-fa-unlock_alt
41
+ folder: '\uf07b', // nf-fa-folder
42
+ folder_open: '\uf07c', // nf-fa-folder_open
43
+ cloud: '\uf0c2', // nf-fa-cloud
44
+ check: '\uf00c', // nf-fa-check
45
+ times: '\uf00d', // nf-fa-times
46
+ circle: '\uf111', // nf-fa-circle
47
+ circle_o: '\uf10c', // nf-fa-circle_o
48
+ arrow_up: '\uf062', // nf-fa-arrow_up
49
+ arrow_down: '\uf063', // nf-fa-arrow_down
50
+ globe: '\uf0ac', // nf-fa-globe
51
+ file: '\uf15b', // nf-fa-file
52
+ code: '\uf121', // nf-fa-code
53
+ clock: '\uf017', // nf-fa-clock_o
54
+ database: '\uf1c0', // nf-fa-database
55
+
56
+ // Octicons (f400-f533)
57
+ git_branch_oct: '\uf418', // nf-oct-git_branch
58
+ repo: '\uf401', // nf-oct-repo
59
+ repo_forked: '\uf402', // nf-oct-repo_forked
60
+ git_commit: '\uf417', // nf-oct-git_commit
61
+ sync: '\uf46a', // nf-oct-sync
62
+ alert: '\uf421', // nf-oct-alert (triangle warning)
63
+ x: '\uf467', // nf-oct-x (x mark)
64
+ issue: '\uf41b', // nf-oct-issue_opened
65
+
66
+ // Seti-UI + Custom (e5fa-e6b7)
67
+ typescript: '\ue628', // nf-seti-typescript
68
+ javascript: '\ue60c', // nf-seti-javascript
69
+ go: '\ue627', // nf-seti-go2
70
+ json: '\ue60b', // nf-seti-json
71
+ lua: '\ue620', // nf-seti-lua
72
+ vue: '\ue6a0', // nf-seti-vue
73
+
74
+ // Powerline symbols
75
+ branch: '\ue0a0', // nf-pl-branch
76
+
77
+ // Custom/MDI
78
+ source_branch: '\uf062c', // source branch
79
+ };
80
+
81
+ // Column widths - must match header widths exactly
82
+ // Note: Icons often display wider than their character count
83
+ export const COL = {
84
+ sel: 4, // "[ ] " selection with trailing space
85
+ status: 10, // status: "⚠ warn" or "✓ ok"
86
+ name: 22, // repo name
87
+ branch: 14, // branch name
88
+ sync: 10, // sync status
89
+ lang: 3, // language icon only
90
+ stars: 5, // star count
91
+ forks: 5, // fork count
92
+ updated: 8, // last updated (shorter)
93
+ size: 6, // repo size
94
+ };
95
+
96
+ // Map language names to Nerd Font icons
97
+ const LANG_ICONS: Record<string, string> = {
98
+ 'typescript': NF.typescript,
99
+ 'javascript': NF.javascript,
100
+ 'python': NF.python,
101
+ 'rust': NF.rust,
102
+ 'go': NF.go,
103
+ 'ruby': NF.ruby,
104
+ 'java': NF.java,
105
+ 'php': NF.php,
106
+ 'swift': NF.swift,
107
+ 'scala': NF.scala,
108
+ 'html': NF.html5,
109
+ 'css': NF.css3,
110
+ 'vue': NF.vue,
111
+ 'haskell': NF.haskell,
112
+ 'erlang': NF.erlang,
113
+ 'clojure': NF.clojure,
114
+ 'lua': NF.lua,
115
+ 'perl': NF.perl,
116
+ 'shell': NF.terminal,
117
+ 'bash': NF.terminal,
118
+ 'dockerfile': NF.docker,
119
+ 'markdown': NF.markdown,
120
+ 'vim': NF.vim,
121
+ 'viml': NF.vim,
122
+ 'jsx': NF.react,
123
+ 'tsx': NF.react,
124
+ };
125
+
126
+ /**
127
+ * Get Nerd Font icon for language
128
+ */
129
+ function getLangIcon(language: string | null): string {
130
+ if (!language) return ' ';
131
+ return LANG_ICONS[language.toLowerCase()] || NF.code;
132
+ }
133
+
134
+ /**
135
+ * Format size - compact
136
+ */
137
+ function formatSize(sizeKB: number | undefined): string {
138
+ if (!sizeKB) return '—'.padStart(COL.size);
139
+ if (sizeKB < 1024) return `${sizeKB}K`.padStart(COL.size);
140
+ if (sizeKB < 1024 * 1024) return `${Math.round(sizeKB / 1024)}M`.padStart(COL.size);
141
+ return `${(sizeKB / 1024 / 1024).toFixed(0)}G`.padStart(COL.size);
142
+ }
143
+
144
+ /**
145
+ * Format count (stars/forks) - compact
146
+ */
147
+ function formatCount(count: number | undefined): string {
148
+ if (count === undefined || count === 0) return '—';
149
+ if (count >= 1000) return `${(count / 1000).toFixed(0)}k`;
150
+ return String(count);
151
+ }
152
+
153
+ /**
154
+ * Format relative date - uses lastModified for non-git/empty repos
155
+ */
156
+ function formatDate(repo: UnifiedRepo): string {
157
+ // Try git commit date first, then lastModified for non-git/empty repos, then GitHub date
158
+ const date = repo.local?.status?.lastLocalCommit
159
+ || repo.local?.lastModified
160
+ || repo.github?.pushedAt;
161
+
162
+ if (!date) return '—'.padStart(COL.updated);
163
+
164
+ const dateObj = typeof date === 'string' ? new Date(date) : date;
165
+ if (isNaN(dateObj.getTime())) return '—'.padStart(COL.updated);
166
+
167
+ const days = Math.floor((Date.now() - dateObj.getTime()) / (1000 * 60 * 60 * 24));
168
+
169
+ let text: string;
170
+ if (days === 0) text = 'today';
171
+ else if (days === 1) text = '1d';
172
+ else if (days < 7) text = `${days}d`;
173
+ else if (days < 30) text = `${Math.floor(days / 7)}w`;
174
+ else if (days < 365) text = `${Math.floor(days / 30)}mo`;
175
+ else text = `${Math.floor(days / 365)}y`;
176
+
177
+ return text.padStart(COL.updated);
178
+ }
179
+
180
+ /**
181
+ * Format sync status with icon - compact
182
+ */
183
+ function formatSync(repo: UnifiedRepo): { text: string; color: string } {
184
+ const status = repo.local?.status;
185
+ if (!status) return { text: '—'.padEnd(COL.sync), color: palette.text.muted };
186
+ if (!status.hasRemote) return { text: '—'.padEnd(COL.sync), color: palette.text.muted }; // Status column shows "local"
187
+
188
+ const ahead = status.isAhead && status.unpushedCommits > 0;
189
+ const behind = status.isBehind && status.unpulledCommits > 0;
190
+
191
+ if (ahead && behind) {
192
+ return { text: `↑${status.unpushedCommits}↓${status.unpulledCommits}`.padEnd(COL.sync), color: palette.badge.dirty.fg };
193
+ }
194
+ if (ahead) {
195
+ return { text: `↑${status.unpushedCommits} push`.padEnd(COL.sync), color: palette.badge.push.fg };
196
+ }
197
+ if (behind) {
198
+ return { text: `↓${status.unpulledCommits} pull`.padEnd(COL.sync), color: palette.badge.pull.fg };
199
+ }
200
+ return { text: `${NF.check} synced`.padEnd(COL.sync), color: palette.badge.ok.fg };
201
+ }
202
+
203
+ /**
204
+ * Truncate string to max length
205
+ */
206
+ function truncate(str: string, max: number): string {
207
+ if (str.length <= max) return str;
208
+ return str.slice(0, max - 1) + '…';
209
+ }
210
+
211
+ /**
212
+ * Health status for a repo - determines what needs attention
213
+ * Priority order (highest first):
214
+ * 1. NO_GIT - folder has no git setup at all
215
+ * 2. NO_COMMITS - git init but no commits
216
+ * 3. NO_REMOTE - has commits but no remote configured
217
+ * 4. DIRTY - has uncommitted changes
218
+ * 5. UNSYNCED - ahead/behind remote
219
+ * 6. OK - everything is good
220
+ */
221
+ type RepoHealth = 'NO_GIT' | 'NO_COMMITS' | 'NO_REMOTE' | 'DIRTY' | 'UNSYNCED' | 'NOT_CLONED' | 'OK';
222
+
223
+ interface HealthInfo {
224
+ status: RepoHealth;
225
+ icon: string;
226
+ label: string;
227
+ color: string;
228
+ bgColor?: string;
229
+ }
230
+
231
+ function getRepoHealth(repo: UnifiedRepo): HealthInfo {
232
+ const status = repo.local?.status;
233
+ const isNonGit = repo.local?.type === 'non-git';
234
+
235
+ // Not cloned (GitHub only)
236
+ if (repo.source === 'github') {
237
+ return {
238
+ status: 'NOT_CLONED',
239
+ icon: NF.cloud,
240
+ label: '',
241
+ color: palette.text.muted,
242
+ };
243
+ }
244
+
245
+ // No git at all (has package.json etc but no .git)
246
+ if (isNonGit) {
247
+ return {
248
+ status: 'NO_GIT',
249
+ icon: NF.alert,
250
+ label: 'init',
251
+ color: palette.badge.local.fg,
252
+ };
253
+ }
254
+
255
+ // Git repo but no commits
256
+ if (status && !status.hasCommits) {
257
+ return {
258
+ status: 'NO_COMMITS',
259
+ icon: NF.alert,
260
+ label: 'empty',
261
+ color: palette.badge.local.fg,
262
+ };
263
+ }
264
+
265
+ // Has commits but no remote
266
+ if (status && !status.hasRemote) {
267
+ return {
268
+ status: 'NO_REMOTE',
269
+ icon: NF.alert,
270
+ label: 'local',
271
+ color: palette.badge.local.fg,
272
+ };
273
+ }
274
+
275
+ // Has dirty working tree
276
+ if (status?.isDirty) {
277
+ return {
278
+ status: 'DIRTY',
279
+ icon: NF.circle,
280
+ label: '',
281
+ color: palette.badge.dirty.fg,
282
+ };
283
+ }
284
+
285
+ // Out of sync with remote
286
+ if (status?.isOutOfSync) {
287
+ return {
288
+ status: 'UNSYNCED',
289
+ icon: NF.sync,
290
+ label: '',
291
+ color: status.isAhead ? palette.badge.push.fg : palette.badge.pull.fg,
292
+ };
293
+ }
294
+
295
+ // All good
296
+ return {
297
+ status: 'OK',
298
+ icon: NF.check,
299
+ label: '',
300
+ color: palette.badge.ok.fg,
301
+ };
302
+ }
303
+
304
+ /**
305
+ * Get source indicator icon (local/github/both)
306
+ */
307
+ function getSourceIcon(repo: UnifiedRepo): { icon: string; color: string } {
308
+ const isPrivate = repo.github?.isPrivate ?? false;
309
+
310
+ if (isPrivate) {
311
+ return { icon: NF.lock, color: palette.text.warning };
312
+ }
313
+
314
+ if (repo.source === 'github') {
315
+ return { icon: NF.cloud, color: palette.text.muted };
316
+ } else if (repo.source === 'local') {
317
+ return { icon: NF.folder, color: palette.text.info };
318
+ } else {
319
+ return { icon: NF.github, color: palette.text.success };
320
+ }
321
+ }
322
+
323
+ export function UnifiedProjectItem({ repo, isSelected, isCursor }: UnifiedProjectItemProps) {
324
+ const bg = isCursor ? palette.bg.accent : isSelected ? palette.bg.surface : undefined;
325
+ const fg = isCursor || isSelected ? palette.text.primary : undefined;
326
+
327
+ // Get health status (most important info)
328
+ const health = getRepoHealth(repo);
329
+
330
+ // Source icon (local/github/both)
331
+ const src = getSourceIcon(repo);
332
+
333
+ // Language icon
334
+ const langIcon = getLangIcon(repo.github?.language || null);
335
+
336
+ // Branch
337
+ const branch = repo.local?.status?.currentBranch;
338
+ const branchText = branch ? truncate(branch, COL.branch - 2) : '—';
339
+
340
+ // Sync status
341
+ const sync = formatSync(repo);
342
+
343
+ // Date (simple string now, no color)
344
+ const dateText = formatDate(repo);
345
+
346
+ // Selection checkbox (only for local repos)
347
+ const checkbox = repo.source === 'github' ? ' '
348
+ : isSelected ? '[x]' : '[ ]';
349
+ const checkColor = isSelected ? palette.text.success : fg;
350
+
351
+ return (
352
+ <Box>
353
+ {/* Cursor */}
354
+ <Text color={fg} backgroundColor={bg}>
355
+ {isCursor ? '>' : ' '}
356
+ </Text>
357
+
358
+ {/* Selection "[x] " or "[ ] " */}
359
+ <Text color={checkColor} backgroundColor={bg}>
360
+ {checkbox.padEnd(COL.sel)}
361
+ </Text>
362
+
363
+ {/* STATUS - icon + optional short label */}
364
+ <Text color={isCursor ? fg : health.color} backgroundColor={bg} bold={isCursor || isSelected}>
365
+ {`${health.label ? `${health.icon} ${health.label} ` : `${health.icon} `}`.padEnd(COL.status)}
366
+ </Text>
367
+
368
+ {/* Source icon (local/github/both/private) */}
369
+ <Text color={isCursor ? fg : src.color} backgroundColor={bg}>
370
+ {src.icon}
371
+ </Text>
372
+ <Text backgroundColor={bg}>{' '}</Text>
373
+
374
+ {/* Name */}
375
+ <Text color={fg || palette.text.primary} backgroundColor={bg} bold={isCursor || isSelected}>
376
+ {truncate(repo.name, COL.name - 2).padEnd(COL.name - 2)}
377
+ </Text>
378
+
379
+ {/* Gap */}
380
+ <Text backgroundColor={bg}>{' '}</Text>
381
+
382
+ {/* Branch */}
383
+ <Text color={isCursor ? fg : palette.text.info} backgroundColor={bg}>
384
+ {branchText.padEnd(COL.branch)}
385
+ </Text>
386
+
387
+ {/* Gap */}
388
+ <Text backgroundColor={bg}>{' '}</Text>
389
+
390
+ {/* Sync status */}
391
+ <Text color={isCursor ? fg : sync.color} backgroundColor={sync.bgColor || bg} bold={!!sync.bgColor}>
392
+ {sync.text}
393
+ </Text>
394
+
395
+ {/* Gap */}
396
+ <Text backgroundColor={bg}>{' '}</Text>
397
+
398
+ {/* Language icon */}
399
+ <Text color={isCursor ? fg : palette.text.info} backgroundColor={bg}>
400
+ {langIcon.padStart(COL.lang)}
401
+ </Text>
402
+
403
+ {/* Gap */}
404
+ <Text backgroundColor={bg}>{' '}</Text>
405
+
406
+ {/* Stars */}
407
+ <Text color={isCursor ? fg : 'yellow'} backgroundColor={bg}>
408
+ {formatCount(repo.github?.stargazersCount).padStart(COL.stars)}
409
+ </Text>
410
+
411
+ {/* Gap */}
412
+ <Text backgroundColor={bg}>{' '}</Text>
413
+
414
+ {/* Forks */}
415
+ <Text color={isCursor ? fg : palette.text.muted} backgroundColor={bg}>
416
+ {formatCount(repo.github?.forksCount).padStart(COL.forks)}
417
+ </Text>
418
+
419
+ {/* Gap */}
420
+ <Text backgroundColor={bg}>{' '}</Text>
421
+
422
+ {/* Updated date */}
423
+ <Text color={isCursor ? fg : palette.text.muted} backgroundColor={bg}>
424
+ {dateText}
425
+ </Text>
426
+
427
+ {/* Gap */}
428
+ <Text backgroundColor={bg}>{' '}</Text>
429
+
430
+ {/* Size */}
431
+ <Text color={isCursor ? fg : palette.text.muted} backgroundColor={bg}>
432
+ {formatSize(repo.github?.size)}
433
+ </Text>
434
+ </Box>
435
+ );
436
+ }
@@ -0,0 +1,37 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import type { ViewMode } from "../types/index.ts";
4
+
5
+ interface ViewModeIndicatorProps {
6
+ mode: ViewMode;
7
+ isLoadingGitHub?: boolean;
8
+ githubError?: string | null;
9
+ }
10
+
11
+ const MODE_CONFIG: Record<ViewMode, { label: string; color: string; icon: string }> = {
12
+ local: { label: "Local", color: "green", icon: "💻" },
13
+ github: { label: "GitHub", color: "magenta", icon: "☁" },
14
+ combined: { label: "All", color: "cyan", icon: "🔗" },
15
+ };
16
+
17
+ export function ViewModeIndicator({ mode, isLoadingGitHub, githubError }: ViewModeIndicatorProps) {
18
+ const config = MODE_CONFIG[mode];
19
+
20
+ return (
21
+ <Box gap={1}>
22
+ <Text color={config.color}>
23
+ {config.icon} [{config.label}]
24
+ </Text>
25
+
26
+ {isLoadingGitHub && (
27
+ <Text color="yellow">⟳</Text>
28
+ )}
29
+
30
+ {githubError && !isLoadingGitHub && (
31
+ <Text color="red">⚠</Text>
32
+ )}
33
+
34
+ <Text dimColor>(Tab)</Text>
35
+ </Box>
36
+ );
37
+ }