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,715 @@
1
+ /**
2
+ * Tests for useKeyBindings hook
3
+ *
4
+ * Note: Testing React hooks that depend on external modules (ink, Bun) requires
5
+ * careful mocking. These tests focus on verifying the key binding logic
6
+ * by testing the dispatch calls that would be made.
7
+ */
8
+
9
+ import { describe, test, expect, beforeAll, beforeEach, afterAll, mock } from "bun:test";
10
+
11
+ // Mock the dependencies at module level
12
+ const mockExit = mock(() => {});
13
+ const mockDispatch = mock((action: any) => {});
14
+ const mockOnRefresh = mock(async () => {});
15
+ const mockBun$ = mock(() => ({ quiet: () => {} }));
16
+
17
+ // Default state factory
18
+ function createDefaultState() {
19
+ return {
20
+ projects: [],
21
+ isLoading: false,
22
+ error: null,
23
+ message: null,
24
+ cursorIndex: 0,
25
+ selectedIndices: new Set(),
26
+ scrollOffset: 0,
27
+ filterText: "",
28
+ quickFilter: "all",
29
+ sortBy: "status",
30
+ sortDirection: "desc",
31
+ mode: "normal",
32
+ actionInProgress: null,
33
+ actionProgress: null,
34
+ confirmDialog: null,
35
+ viewMode: "combined",
36
+ detailModal: null,
37
+ cloneDialog: null,
38
+ refreshing: false,
39
+ unifiedRepos: [],
40
+ githubRepos: [],
41
+ isLoadingGitHub: false,
42
+ githubError: null,
43
+ isRefreshing: false,
44
+ languageFilter: null,
45
+ };
46
+ }
47
+
48
+ // Helper to simulate key input
49
+ function simulateKeyInput(input: string, key: any = {}) {
50
+ const handler = (globalThis as any).__testInputHandler;
51
+ if (!handler) {
52
+ throw new Error("useInput handler not found. Make sure to initialize useKeyBindings first.");
53
+ }
54
+ handler(input, key);
55
+ }
56
+
57
+ // Helper to set test state
58
+ function setTestState(state: any) {
59
+ (globalThis as any).__testState = { ...createDefaultState(), ...state };
60
+ }
61
+
62
+ // Mock config
63
+ const mockConfig = {
64
+ directories: [{ path: "/test", maxDepth: 2 }],
65
+ scan: {
66
+ ignore: ["node_modules", ".git"],
67
+ includeHidden: false,
68
+ concurrency: 5
69
+ },
70
+ github: { defaultVisibility: "private" as const },
71
+ display: {
72
+ showSubmodules: true,
73
+ showNonGitProjects: true,
74
+ sortBy: "status" as const,
75
+ sortDirection: "desc" as const,
76
+ },
77
+ cache: {
78
+ ttlSeconds: 300,
79
+ githubTtlSeconds: 600,
80
+ enableBackgroundRefresh: true,
81
+ backgroundRefreshIntervalSeconds: 300,
82
+ },
83
+ commands: [
84
+ { key: "t", name: "test", command: "npm test", confirm: false, background: false },
85
+ { key: "b", name: "build", command: "npm run build", confirm: false, background: false },
86
+ ],
87
+ };
88
+
89
+ describe("useKeyBindings", () => {
90
+ beforeAll(() => {
91
+ // Set up module mocks before all tests
92
+ mock.module("ink", () => ({
93
+ useInput: (handler: (input: string, key: any) => void) => {
94
+ // Store the handler globally for testing
95
+ (globalThis as any).__testInputHandler = handler;
96
+ },
97
+ useApp: () => ({ exit: mockExit }),
98
+ }));
99
+
100
+ mock.module("../../../src/state/store.tsx", () => ({
101
+ useStore: () => ({
102
+ state: (globalThis as any).__testState || createDefaultState(),
103
+ dispatch: mockDispatch,
104
+ }),
105
+ useFilteredProjects: () => [],
106
+ useSelectedProjects: () => [],
107
+ useFilteredUnifiedRepos: () => (globalThis as any).__testState?.unifiedRepos || [],
108
+ useSelectedUnifiedRepos: () => [],
109
+ }));
110
+
111
+ mock.module("../../../src/operations/batch.ts", () => ({
112
+ batchPull: mock(() => Promise.resolve({ total: 0, successful: 0, failed: 0, results: [], duration: 0 })),
113
+ batchPush: mock(() => Promise.resolve({ total: 0, successful: 0, failed: 0, results: [], duration: 0 })),
114
+ batchFetch: mock(() => Promise.resolve({ total: 0, successful: 0, failed: 0, results: [], duration: 0 })),
115
+ }));
116
+
117
+ mock.module("../../../src/git/operations.ts", () => ({
118
+ initGitInProject: mock(() => Promise.resolve({ success: true })),
119
+ }));
120
+
121
+ mock.module("../../../src/operations/commands.ts", () => ({
122
+ executeCommand: mock(() => Promise.resolve({ success: true, output: "" })),
123
+ findCommandByKey: mock(() => null),
124
+ }));
125
+
126
+ mock.module("../../../src/utils/errors.ts", () => ({
127
+ errorToString: mock((error: any) => String(error)),
128
+ }));
129
+
130
+ // Mock Bun
131
+ mock.module("bun", () => ({
132
+ $: mockBun$,
133
+ file: mock((path: string) => ({
134
+ exists: mock(() => Promise.resolve(false)),
135
+ text: mock(() => Promise.resolve("")),
136
+ })),
137
+ }));
138
+ });
139
+
140
+ beforeEach(() => {
141
+ // Clear all mocks
142
+ mockDispatch.mockClear();
143
+ mockExit.mockClear();
144
+ mockOnRefresh.mockClear();
145
+ mockBun$.mockClear();
146
+
147
+ // Reset test state
148
+ setTestState({});
149
+ (globalThis as any).__testInputHandler = null;
150
+ });
151
+
152
+ afterAll(() => {
153
+ // Restore mocks after all tests complete
154
+ mock.restore();
155
+ });
156
+
157
+ describe("Navigation tests", () => {
158
+ test("j moves cursor down", async () => {
159
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
160
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
161
+
162
+ simulateKeyInput("j");
163
+
164
+ expect(mockDispatch).toHaveBeenCalledWith({
165
+ type: "MOVE_CURSOR",
166
+ payload: 1,
167
+ });
168
+ });
169
+
170
+ test("down arrow moves cursor down", async () => {
171
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
172
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
173
+
174
+ simulateKeyInput("", { downArrow: true });
175
+
176
+ expect(mockDispatch).toHaveBeenCalledWith({
177
+ type: "MOVE_CURSOR",
178
+ payload: 1,
179
+ });
180
+ });
181
+
182
+ test("k moves cursor up", async () => {
183
+ setTestState({ cursorIndex: 5 });
184
+
185
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
186
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
187
+
188
+ simulateKeyInput("k");
189
+
190
+ expect(mockDispatch).toHaveBeenCalledWith({
191
+ type: "MOVE_CURSOR",
192
+ payload: 4,
193
+ });
194
+ });
195
+
196
+ test("up arrow moves cursor up", async () => {
197
+ setTestState({ cursorIndex: 3 });
198
+
199
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
200
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
201
+
202
+ simulateKeyInput("", { upArrow: true });
203
+
204
+ expect(mockDispatch).toHaveBeenCalledWith({
205
+ type: "MOVE_CURSOR",
206
+ payload: 2,
207
+ });
208
+ });
209
+
210
+ test("g moves to first item", async () => {
211
+ setTestState({ cursorIndex: 10 });
212
+
213
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
214
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
215
+
216
+ simulateKeyInput("g");
217
+
218
+ expect(mockDispatch).toHaveBeenCalledWith({
219
+ type: "MOVE_CURSOR",
220
+ payload: 0,
221
+ });
222
+ });
223
+
224
+ test("G moves to last item", async () => {
225
+ setTestState({
226
+ cursorIndex: 0,
227
+ viewMode: "combined",
228
+ unifiedRepos: [1, 2, 3], // Mock 3 items
229
+ });
230
+
231
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
232
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
233
+
234
+ simulateKeyInput("G");
235
+
236
+ // Should move to last item (2)
237
+ expect(mockDispatch).toHaveBeenCalledWith({
238
+ type: "MOVE_CURSOR",
239
+ payload: 2,
240
+ });
241
+ });
242
+ });
243
+
244
+ describe("Selection tests", () => {
245
+ test("space toggles selection", async () => {
246
+ setTestState({ cursorIndex: 2 });
247
+
248
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
249
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
250
+
251
+ simulateKeyInput(" ");
252
+
253
+ expect(mockDispatch).toHaveBeenCalledWith({
254
+ type: "TOGGLE_SELECTION",
255
+ payload: 2,
256
+ });
257
+ });
258
+
259
+ test("a selects all when none selected", async () => {
260
+ setTestState({
261
+ selectedIndices: new Set(),
262
+ viewMode: "combined",
263
+ unifiedRepos: [1, 2], // Mock 2 items
264
+ });
265
+
266
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
267
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
268
+
269
+ simulateKeyInput("a");
270
+
271
+ expect(mockDispatch).toHaveBeenCalledWith({
272
+ type: "SELECT_ALL",
273
+ });
274
+ });
275
+
276
+ test("a deselects all when all selected", async () => {
277
+ setTestState({
278
+ selectedIndices: new Set([0, 1]),
279
+ viewMode: "combined",
280
+ unifiedRepos: [1, 2], // Mock 2 items
281
+ });
282
+
283
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
284
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
285
+
286
+ simulateKeyInput("a");
287
+
288
+ expect(mockDispatch).toHaveBeenCalledWith({
289
+ type: "DESELECT_ALL",
290
+ });
291
+ });
292
+ });
293
+
294
+ describe("Filter tests", () => {
295
+ test("/ enters filter mode", async () => {
296
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
297
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
298
+
299
+ simulateKeyInput("/");
300
+
301
+ expect(mockDispatch).toHaveBeenCalledWith({
302
+ type: "SET_MODE",
303
+ payload: "filter",
304
+ });
305
+ });
306
+
307
+ test("escape exits filter mode", async () => {
308
+ setTestState({ mode: "filter" });
309
+
310
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
311
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
312
+
313
+ simulateKeyInput("", { escape: true });
314
+
315
+ expect(mockDispatch).toHaveBeenCalledWith({
316
+ type: "SET_MODE",
317
+ payload: "normal",
318
+ });
319
+ expect(mockDispatch).toHaveBeenCalledWith({
320
+ type: "SET_FILTER",
321
+ payload: "",
322
+ });
323
+ });
324
+
325
+ test("return exits filter mode", async () => {
326
+ setTestState({ mode: "filter" });
327
+
328
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
329
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
330
+
331
+ simulateKeyInput("", { return: true });
332
+
333
+ expect(mockDispatch).toHaveBeenCalledWith({
334
+ type: "SET_MODE",
335
+ payload: "normal",
336
+ });
337
+ });
338
+ });
339
+
340
+ describe("Quick filter tests", () => {
341
+ const quickFilterTests = [
342
+ { key: "0", filter: "all", name: "All projects" },
343
+ { key: "1", filter: "dirty", name: "Dirty projects" },
344
+ { key: "2", filter: "unpushed", name: "Unpushed commits" },
345
+ { key: "3", filter: "no-remote", name: "No remote" },
346
+ { key: "4", filter: "github-only", name: "GitHub only" },
347
+ { key: "5", filter: "local-only", name: "Local only" },
348
+ { key: "6", filter: "private", name: "Private repos" },
349
+ { key: "7", filter: "public", name: "Public repos" },
350
+ { key: "8", filter: "archived", name: "Archived repos" },
351
+ { key: "9", filter: "forks", name: "Forked repos" },
352
+ ];
353
+
354
+ for (const { key, filter, name } of quickFilterTests) {
355
+ test(`${key} applies ${filter} quick filter`, async () => {
356
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
357
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
358
+
359
+ simulateKeyInput(key);
360
+
361
+ expect(mockDispatch).toHaveBeenCalledWith({
362
+ type: "SET_QUICK_FILTER",
363
+ payload: filter,
364
+ });
365
+ expect(mockDispatch).toHaveBeenCalledWith({
366
+ type: "SET_MESSAGE",
367
+ payload: `Filter: ${name}`,
368
+ });
369
+ });
370
+ }
371
+ });
372
+
373
+ describe("Sort tests", () => {
374
+ test("s cycles sort field", async () => {
375
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
376
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
377
+
378
+ simulateKeyInput("s");
379
+
380
+ expect(mockDispatch).toHaveBeenCalledWith({
381
+ type: "CYCLE_SORT",
382
+ });
383
+ });
384
+
385
+ test("S reverses sort direction", async () => {
386
+ setTestState({
387
+ sortBy: "name",
388
+ sortDirection: "desc"
389
+ });
390
+
391
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
392
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
393
+
394
+ simulateKeyInput("S");
395
+
396
+ expect(mockDispatch).toHaveBeenCalledWith({
397
+ type: "SET_SORT",
398
+ payload: { by: "name", direction: "asc" },
399
+ });
400
+ expect(mockDispatch).toHaveBeenCalledWith({
401
+ type: "SET_MESSAGE",
402
+ payload: "Sort: name ↑",
403
+ });
404
+ });
405
+
406
+ test("S changes from asc to desc", async () => {
407
+ setTestState({
408
+ sortBy: "name",
409
+ sortDirection: "asc"
410
+ });
411
+
412
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
413
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
414
+
415
+ simulateKeyInput("S");
416
+
417
+ expect(mockDispatch).toHaveBeenCalledWith({
418
+ type: "SET_SORT",
419
+ payload: { by: "name", direction: "desc" },
420
+ });
421
+ expect(mockDispatch).toHaveBeenCalledWith({
422
+ type: "SET_MESSAGE",
423
+ payload: "Sort: name ↓",
424
+ });
425
+ });
426
+ });
427
+
428
+ describe("View mode tests", () => {
429
+ test("tab cycles view modes", async () => {
430
+ setTestState({ viewMode: "local" });
431
+
432
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
433
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
434
+
435
+ simulateKeyInput("", { tab: true });
436
+
437
+ expect(mockDispatch).toHaveBeenCalledWith({
438
+ type: "SET_VIEW_MODE",
439
+ payload: "github",
440
+ });
441
+ expect(mockDispatch).toHaveBeenCalledWith({
442
+ type: "SET_MESSAGE",
443
+ payload: "View: GitHub only",
444
+ });
445
+ });
446
+
447
+ test("tab cycles from github to combined", async () => {
448
+ setTestState({ viewMode: "github" });
449
+
450
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
451
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
452
+
453
+ simulateKeyInput("", { tab: true });
454
+
455
+ expect(mockDispatch).toHaveBeenCalledWith({
456
+ type: "SET_VIEW_MODE",
457
+ payload: "combined",
458
+ });
459
+ expect(mockDispatch).toHaveBeenCalledWith({
460
+ type: "SET_MESSAGE",
461
+ payload: "View: All repos",
462
+ });
463
+ });
464
+
465
+ test("tab cycles from combined to local", async () => {
466
+ setTestState({ viewMode: "combined" });
467
+
468
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
469
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
470
+
471
+ simulateKeyInput("", { tab: true });
472
+
473
+ expect(mockDispatch).toHaveBeenCalledWith({
474
+ type: "SET_VIEW_MODE",
475
+ payload: "local",
476
+ });
477
+ expect(mockDispatch).toHaveBeenCalledWith({
478
+ type: "SET_MESSAGE",
479
+ payload: "View: Local only",
480
+ });
481
+ });
482
+ });
483
+
484
+ describe("Mode switching tests", () => {
485
+ test("? opens help mode", async () => {
486
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
487
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
488
+
489
+ simulateKeyInput("?");
490
+
491
+ expect(mockDispatch).toHaveBeenCalledWith({
492
+ type: "SET_MODE",
493
+ payload: "help",
494
+ });
495
+ });
496
+
497
+ test("F opens filter-options mode", async () => {
498
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
499
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
500
+
501
+ simulateKeyInput("F");
502
+
503
+ expect(mockDispatch).toHaveBeenCalledWith({
504
+ type: "SET_MODE",
505
+ payload: "filter-options",
506
+ });
507
+ });
508
+
509
+ test("x opens command-palette mode", async () => {
510
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
511
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
512
+
513
+ simulateKeyInput("x");
514
+
515
+ expect(mockDispatch).toHaveBeenCalledWith({
516
+ type: "SET_MODE",
517
+ payload: "command-palette",
518
+ });
519
+ });
520
+
521
+ test("x shows message when no commands configured", async () => {
522
+ const configWithNoCommands = { ...mockConfig, commands: [] };
523
+
524
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
525
+ useKeyBindings({ config: configWithNoCommands, onRefresh: mockOnRefresh });
526
+
527
+ simulateKeyInput("x");
528
+
529
+ expect(mockDispatch).toHaveBeenCalledWith({
530
+ type: "SET_MESSAGE",
531
+ payload: "No commands configured",
532
+ });
533
+ });
534
+
535
+ test("q exits app", async () => {
536
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
537
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
538
+
539
+ simulateKeyInput("q");
540
+
541
+ expect(mockExit).toHaveBeenCalled();
542
+ });
543
+
544
+ test("Ctrl+C exits app", async () => {
545
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
546
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
547
+
548
+ simulateKeyInput("c", { ctrl: true });
549
+
550
+ expect(mockExit).toHaveBeenCalled();
551
+ });
552
+ });
553
+
554
+ describe("Detail modal tests", () => {
555
+ test("escape closes detail modal", async () => {
556
+ setTestState({ mode: "detail" });
557
+
558
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
559
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
560
+
561
+ simulateKeyInput("", { escape: true });
562
+
563
+ expect(mockDispatch).toHaveBeenCalledWith({
564
+ type: "HIDE_DETAIL_MODAL",
565
+ });
566
+ });
567
+
568
+ test("j scrolls README down in detail modal", async () => {
569
+ setTestState({
570
+ mode: "detail",
571
+ detailModal: { readmeScrollOffset: 5 },
572
+ });
573
+
574
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
575
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
576
+
577
+ simulateKeyInput("j");
578
+
579
+ expect(mockDispatch).toHaveBeenCalledWith({
580
+ type: "UPDATE_DETAIL_MODAL",
581
+ payload: { readmeScrollOffset: 6 },
582
+ });
583
+ });
584
+
585
+ test("k scrolls README up in detail modal", async () => {
586
+ setTestState({
587
+ mode: "detail",
588
+ detailModal: { readmeScrollOffset: 5 },
589
+ });
590
+
591
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
592
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
593
+
594
+ simulateKeyInput("k");
595
+
596
+ expect(mockDispatch).toHaveBeenCalledWith({
597
+ type: "UPDATE_DETAIL_MODAL",
598
+ payload: { readmeScrollOffset: 4 },
599
+ });
600
+ });
601
+
602
+ test("k prevents negative scroll offset", async () => {
603
+ setTestState({
604
+ mode: "detail",
605
+ detailModal: { readmeScrollOffset: 0 },
606
+ });
607
+
608
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
609
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
610
+
611
+ simulateKeyInput("k");
612
+
613
+ expect(mockDispatch).toHaveBeenCalledWith({
614
+ type: "UPDATE_DETAIL_MODAL",
615
+ payload: { readmeScrollOffset: 0 },
616
+ });
617
+ });
618
+ });
619
+
620
+ describe("Help and filter options mode tests", () => {
621
+ test("any key closes help mode", async () => {
622
+ setTestState({ mode: "help" });
623
+
624
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
625
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
626
+
627
+ simulateKeyInput("a");
628
+
629
+ expect(mockDispatch).toHaveBeenCalledWith({
630
+ type: "SET_MODE",
631
+ payload: "normal",
632
+ });
633
+ });
634
+
635
+ test("any key closes filter-options mode", async () => {
636
+ setTestState({ mode: "filter-options" });
637
+
638
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
639
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
640
+
641
+ simulateKeyInput("b");
642
+
643
+ expect(mockDispatch).toHaveBeenCalledWith({
644
+ type: "SET_MODE",
645
+ payload: "normal",
646
+ });
647
+ });
648
+ });
649
+
650
+ describe("Clone dialog tests", () => {
651
+ test("escape closes clone dialog", async () => {
652
+ setTestState({ mode: "clone" });
653
+
654
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
655
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
656
+
657
+ simulateKeyInput("", { escape: true });
658
+
659
+ expect(mockDispatch).toHaveBeenCalledWith({
660
+ type: "HIDE_CLONE_DIALOG",
661
+ });
662
+ expect(mockDispatch).toHaveBeenCalledWith({
663
+ type: "SET_MODE",
664
+ payload: "normal",
665
+ });
666
+ });
667
+ });
668
+
669
+ describe("Command palette tests", () => {
670
+ test("escape closes command palette", async () => {
671
+ setTestState({ mode: "command-palette" });
672
+
673
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
674
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
675
+
676
+ simulateKeyInput("", { escape: true });
677
+
678
+ expect(mockDispatch).toHaveBeenCalledWith({
679
+ type: "SET_MODE",
680
+ payload: "normal",
681
+ });
682
+ });
683
+ });
684
+
685
+ describe("Refresh tests", () => {
686
+ test("r triggers refresh", async () => {
687
+ const { useKeyBindings } = await import("../../../src/hooks/useKeyBindings.ts");
688
+ useKeyBindings({ config: mockConfig, onRefresh: mockOnRefresh });
689
+
690
+ simulateKeyInput("r");
691
+
692
+ // Wait for async operations
693
+ await new Promise(resolve => setTimeout(resolve, 0));
694
+
695
+ expect(mockDispatch).toHaveBeenCalledWith({
696
+ type: "SET_REFRESHING",
697
+ payload: true,
698
+ });
699
+ expect(mockOnRefresh).toHaveBeenCalled();
700
+ expect(mockDispatch).toHaveBeenCalledWith({
701
+ type: "SET_REFRESHING",
702
+ payload: false,
703
+ });
704
+ expect(mockDispatch).toHaveBeenCalledWith({
705
+ type: "SET_MESSAGE",
706
+ payload: "Refresh complete",
707
+ });
708
+ });
709
+ });
710
+ });
711
+
712
+ // Restore all mocks after all tests complete
713
+ afterAll(() => {
714
+ mock.restore();
715
+ });