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,17 @@
1
+ /**
2
+ * Error handling utilities
3
+ */
4
+
5
+ /**
6
+ * Safely convert an unknown error to a string message.
7
+ * Handles Error objects, strings, and unknown types gracefully.
8
+ */
9
+ export function errorToString(error: unknown): string {
10
+ if (error instanceof Error) {
11
+ return error.message;
12
+ }
13
+ if (typeof error === "string") {
14
+ return error;
15
+ }
16
+ return String(error);
17
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Utility exports
3
+ * Pure functions for filtering and sorting (no dependencies on state)
4
+ */
5
+
6
+ export { sortProjects, filterProjects } from "./project-utils.ts";
7
+ export { errorToString } from "./errors.ts";
8
+ export { withTimeout, createTimeoutController, TimeoutError } from "./timeout.ts";
@@ -0,0 +1,230 @@
1
+ export interface MarkdownNode {
2
+ type: 'heading' | 'paragraph' | 'code' | 'codeblock' | 'list' | 'listitem' | 'blockquote' | 'hr' | 'link' | 'bold' | 'italic' | 'text';
3
+ content?: string;
4
+ level?: number; // for headings (1-6)
5
+ language?: string; // for code blocks
6
+ url?: string; // for links
7
+ children?: MarkdownNode[];
8
+ }
9
+
10
+ export function parseMarkdown(markdown: string): MarkdownNode[] {
11
+ const lines = markdown.split('\n');
12
+ const nodes: MarkdownNode[] = [];
13
+ let i = 0;
14
+
15
+ while (i < lines.length) {
16
+ const line = lines[i];
17
+
18
+ // Skip empty lines
19
+ if (!line?.trim()) {
20
+ i++;
21
+ continue;
22
+ }
23
+
24
+ // Horizontal rule
25
+ if (/^---+\s*$/.test(line)) {
26
+ nodes.push({ type: 'hr' });
27
+ i++;
28
+ continue;
29
+ }
30
+
31
+ // Code block
32
+ if (line.startsWith('```')) {
33
+ const language = line.slice(3).trim();
34
+ const content: string[] = [];
35
+ i++;
36
+ while (i < lines.length && !lines[i]?.startsWith('```')) {
37
+ content.push(lines[i] || '');
38
+ i++;
39
+ }
40
+ nodes.push({
41
+ type: 'codeblock',
42
+ content: content.join('\n'),
43
+ language: language || undefined
44
+ });
45
+ i++; // Skip closing ```
46
+ continue;
47
+ }
48
+
49
+ // Blockquote
50
+ if (line.startsWith('>')) {
51
+ const content = line.replace(/^>\s?/, '');
52
+ nodes.push({
53
+ type: 'blockquote',
54
+ content: content
55
+ });
56
+ i++;
57
+ continue;
58
+ }
59
+
60
+ // Heading
61
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
62
+ if (headingMatch) {
63
+ nodes.push({
64
+ type: 'heading',
65
+ level: headingMatch[1]?.length || 1,
66
+ content: headingMatch[2] || ''
67
+ });
68
+ i++;
69
+ continue;
70
+ }
71
+
72
+ // List item
73
+ const listMatch = line.match(/^([\s]*)[-*+]\s+(.+)$/);
74
+ if (listMatch) {
75
+ const listNodes: MarkdownNode[] = [];
76
+
77
+ while (i < lines.length) {
78
+ const currentLine = lines[i];
79
+ const currentMatch = currentLine?.match(/^([\s]*)[-*+]\s+(.+)$/);
80
+
81
+ if (!currentMatch) break;
82
+
83
+ listNodes.push({
84
+ type: 'listitem',
85
+ content: currentMatch[2] || ''
86
+ });
87
+ i++;
88
+ }
89
+
90
+ nodes.push({
91
+ type: 'list',
92
+ children: listNodes
93
+ });
94
+ continue;
95
+ }
96
+
97
+ // Paragraph
98
+ nodes.push(parseParagraph(line));
99
+ i++;
100
+ }
101
+
102
+ return nodes;
103
+ }
104
+
105
+ function parseParagraph(text: string): MarkdownNode {
106
+ const children: MarkdownNode[] = [];
107
+ let remaining = text;
108
+
109
+ while (remaining) {
110
+ // Check for code
111
+ const codeMatch = remaining.match(/^`([^`]+)`/);
112
+ if (codeMatch) {
113
+ children.push({
114
+ type: 'code',
115
+ content: codeMatch[1]
116
+ });
117
+ remaining = remaining.slice(codeMatch[0].length);
118
+ continue;
119
+ }
120
+
121
+ // Check for bold
122
+ const boldMatch = remaining.match(/^\*\*([^*]+)\*\*/);
123
+ if (boldMatch) {
124
+ children.push({
125
+ type: 'bold',
126
+ content: boldMatch[1]
127
+ });
128
+ remaining = remaining.slice(boldMatch[0].length);
129
+ continue;
130
+ }
131
+
132
+ // Check for italic
133
+ const italicMatch = remaining.match(/^\*([^*]+)\*/);
134
+ if (italicMatch) {
135
+ children.push({
136
+ type: 'italic',
137
+ content: italicMatch[1]
138
+ });
139
+ remaining = remaining.slice(italicMatch[0].length);
140
+ continue;
141
+ }
142
+
143
+ // Check for link
144
+ const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
145
+ if (linkMatch) {
146
+ children.push({
147
+ type: 'link',
148
+ content: linkMatch[1],
149
+ url: linkMatch[2]
150
+ });
151
+ remaining = remaining.slice(linkMatch[0].length);
152
+ continue;
153
+ }
154
+
155
+ // Regular text - find next special character
156
+ const nextSpecial = remaining.search(/[`\*\[]/);
157
+ if (nextSpecial === -1) {
158
+ children.push({
159
+ type: 'text',
160
+ content: remaining
161
+ });
162
+ break;
163
+ } else if (nextSpecial > 0) {
164
+ children.push({
165
+ type: 'text',
166
+ content: remaining.slice(0, nextSpecial)
167
+ });
168
+ remaining = remaining.slice(nextSpecial);
169
+ } else {
170
+ // Special character at start but no match - treat as text
171
+ children.push({
172
+ type: 'text',
173
+ content: remaining[0]
174
+ });
175
+ remaining = remaining.slice(1);
176
+ }
177
+ }
178
+
179
+ return {
180
+ type: 'paragraph',
181
+ children
182
+ };
183
+ }
184
+
185
+ export function truncateMarkdown(nodes: MarkdownNode[], maxLines: number): MarkdownNode[] {
186
+ const lines: MarkdownNode[] = [];
187
+
188
+ function countLines(node: MarkdownNode): number {
189
+ if (node.type === 'text' || node.type === 'code' || node.type === 'bold' ||
190
+ node.type === 'italic' || node.type === 'link') {
191
+ return Math.ceil((node.content?.length || 0) / 80) || 1;
192
+ }
193
+ if (node.type === 'paragraph') {
194
+ return 1;
195
+ }
196
+ if (node.type === 'heading') {
197
+ return 1;
198
+ }
199
+ if (node.type === 'codeblock') {
200
+ return (node.content?.split('\n').length || 0) + 2; // +2 for borders
201
+ }
202
+ if (node.type === 'list' && node.children) {
203
+ return node.children.length;
204
+ }
205
+ if (node.type === 'listitem' || node.type === 'blockquote') {
206
+ return 1;
207
+ }
208
+ if (node.type === 'hr') {
209
+ return 1;
210
+ }
211
+ return 1;
212
+ }
213
+
214
+ function addNode(node: MarkdownNode): boolean {
215
+ const linesNeeded = countLines(node);
216
+ if (lines.length + linesNeeded > maxLines) {
217
+ return false;
218
+ }
219
+ lines.push(node);
220
+ return true;
221
+ }
222
+
223
+ for (const node of nodes) {
224
+ if (!addNode(node)) {
225
+ break;
226
+ }
227
+ }
228
+
229
+ return lines;
230
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Utility functions for filtering and sorting projects
3
+ * These are extracted to avoid circular dependencies between state and scanner
4
+ */
5
+
6
+ import type { Project, SortField, SortDirection } from "../types/index.ts";
7
+
8
+ /**
9
+ * Get status priority for sorting (lower = more attention needed)
10
+ */
11
+ function getStatusPriority(project: Project): number {
12
+ if (project.type === "non-git") return 100;
13
+ if (!project.status) return 99;
14
+
15
+ let priority = 0;
16
+
17
+ // Dirty repos need attention
18
+ if (project.status.isDirty) priority -= 40;
19
+
20
+ // Out of sync repos need attention
21
+ if (project.status.isAhead) priority -= 20;
22
+ if (project.status.isBehind) priority -= 30;
23
+
24
+ // No remote = might need setup
25
+ if (!project.status.hasRemote) priority -= 10;
26
+
27
+ return priority;
28
+ }
29
+
30
+ /**
31
+ * Sort projects based on configuration
32
+ */
33
+ export function sortProjects(
34
+ projects: Project[],
35
+ sortBy: SortField,
36
+ direction: SortDirection
37
+ ): Project[] {
38
+ const sorted = [...projects].sort((a, b) => {
39
+ let comparison = 0;
40
+
41
+ switch (sortBy) {
42
+ case "name":
43
+ comparison = a.name.localeCompare(b.name);
44
+ break;
45
+
46
+ case "branch": {
47
+ const aBranch = a.status?.currentBranch ?? "";
48
+ const bBranch = b.status?.currentBranch ?? "";
49
+ comparison = aBranch.localeCompare(bBranch);
50
+ break;
51
+ }
52
+
53
+ case "status":
54
+ // Lower priority = needs more attention
55
+ // For "desc": most attention-needed first (lowest priority numbers first)
56
+ // For "asc": least attention-needed first (highest priority numbers first)
57
+ comparison = getStatusPriority(a) - getStatusPriority(b);
58
+ // Don't invert for desc - we want lower priority (more important) first
59
+ return direction === "desc" ? comparison : -comparison;
60
+
61
+ case "sync": {
62
+ const aStatus = a.status;
63
+ const bStatus = b.status;
64
+ const aDelta = aStatus ? (aStatus.unpushedCommits ?? 0) + (aStatus.unpulledCommits ?? 0) : 0;
65
+ const bDelta = bStatus ? (bStatus.unpushedCommits ?? 0) + (bStatus.unpulledCommits ?? 0) : 0;
66
+ comparison = aDelta - bDelta;
67
+ break;
68
+ }
69
+
70
+ case "language": {
71
+ const aLang = (a.status as any)?.language || "";
72
+ const bLang = (b.status as any)?.language || "";
73
+ comparison = aLang.localeCompare(bLang);
74
+ break;
75
+ }
76
+
77
+ case "lastActivity":
78
+ // Handle both Date objects and string timestamps
79
+ const aTime = a.status?.lastLocalCommit ?
80
+ (typeof a.status.lastLocalCommit === 'string' ?
81
+ new Date(a.status.lastLocalCommit).getTime() :
82
+ a.status.lastLocalCommit.getTime()) : 0;
83
+ const bTime = b.status?.lastLocalCommit ?
84
+ (typeof b.status.lastLocalCommit === 'string' ?
85
+ new Date(b.status.lastLocalCommit).getTime() :
86
+ b.status.lastLocalCommit.getTime()) : 0;
87
+ // For desc: most recent first (higher date value first)
88
+ // For asc: oldest first (lower date value first)
89
+ comparison = aTime - bTime;
90
+ break;
91
+
92
+ case "stars":
93
+ case "forks":
94
+ case "size":
95
+ // Projects don't include GitHub metadata directly; keep stable
96
+ comparison = 0;
97
+ break;
98
+ }
99
+
100
+ return direction === "desc" ? -comparison : comparison;
101
+ });
102
+
103
+ return sorted;
104
+ }
105
+
106
+ /**
107
+ * Filter projects based on text search
108
+ */
109
+ export function filterProjects(projects: Project[], filterText: string): Project[] {
110
+ if (!filterText.trim()) return projects;
111
+
112
+ const lower = filterText.toLowerCase();
113
+
114
+ return projects.filter((p) => {
115
+ // Match name
116
+ if (p.name.toLowerCase().includes(lower)) return true;
117
+
118
+ // Match path
119
+ if (p.path.toLowerCase().includes(lower)) return true;
120
+
121
+ // Match project marker
122
+ if (p.projectMarker?.toLowerCase().includes(lower)) return true;
123
+
124
+ // Match type
125
+ if (p.type.toLowerCase().includes(lower)) return true;
126
+
127
+ return false;
128
+ });
129
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Rate limiting utilities for batch operations
3
+ */
4
+
5
+ /**
6
+ * Semaphore for controlling concurrent operations
7
+ */
8
+ export class Semaphore {
9
+ private permits: number;
10
+ private waiting: Array<() => void> = [];
11
+
12
+ constructor(maxConcurrent: number) {
13
+ this.permits = maxConcurrent;
14
+ }
15
+
16
+ async acquire(): Promise<void> {
17
+ if (this.permits > 0) {
18
+ this.permits--;
19
+ return;
20
+ }
21
+
22
+ return new Promise<void>((resolve) => {
23
+ this.waiting.push(resolve);
24
+ });
25
+ }
26
+
27
+ release(): void {
28
+ if (this.waiting.length > 0) {
29
+ const next = this.waiting.shift();
30
+ if (next) next();
31
+ } else {
32
+ this.permits++;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Execute a function with semaphore protection
38
+ */
39
+ async run<T>(fn: () => Promise<T>): Promise<T> {
40
+ await this.acquire();
41
+ try {
42
+ return await fn();
43
+ } finally {
44
+ this.release();
45
+ }
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Rate limiter that enforces a minimum delay between operations
51
+ */
52
+ export class RateLimiter {
53
+ private lastOperation: number = 0;
54
+
55
+ constructor(private minDelayMs: number) {}
56
+
57
+ async wait(): Promise<void> {
58
+ const now = Date.now();
59
+ const elapsed = now - this.lastOperation;
60
+
61
+ if (elapsed < this.minDelayMs) {
62
+ await new Promise((resolve) =>
63
+ setTimeout(resolve, this.minDelayMs - elapsed)
64
+ );
65
+ }
66
+
67
+ this.lastOperation = Date.now();
68
+ }
69
+ }
70
+
71
+ export interface BatchItemResult<R> {
72
+ success: boolean;
73
+ result?: R;
74
+ error?: Error;
75
+ }
76
+
77
+ export interface ProcessBatchOptions<T, R> {
78
+ /** Maximum concurrent operations (default: 5) */
79
+ concurrency?: number;
80
+ /** Minimum delay between operations in ms (default: 0) */
81
+ minDelay?: number;
82
+ /** Progress callback */
83
+ onProgress?: (completed: number, total: number) => void;
84
+ /** Error handler - return true to continue, false to stop */
85
+ onError?: (error: Error, item: T) => boolean;
86
+ /** Optional result transformer */
87
+ transform?: (result: R) => R;
88
+ }
89
+
90
+ /**
91
+ * Process items in batches with rate limiting
92
+ */
93
+ export async function processBatch<T, R>(
94
+ items: T[],
95
+ processor: (item: T) => Promise<R>,
96
+ options: ProcessBatchOptions<T, R> = {}
97
+ ): Promise<BatchItemResult<R>[]> {
98
+ const {
99
+ concurrency = 5,
100
+ minDelay = 0,
101
+ onProgress,
102
+ onError,
103
+ } = options;
104
+
105
+ const semaphore = new Semaphore(concurrency);
106
+ const rateLimiter = minDelay > 0 ? new RateLimiter(minDelay) : null;
107
+ let completed = 0;
108
+
109
+ const processItem = async (item: T): Promise<BatchItemResult<R>> => {
110
+ return semaphore.run(async () => {
111
+ if (rateLimiter) {
112
+ await rateLimiter.wait();
113
+ }
114
+
115
+ try {
116
+ const result = await processor(item);
117
+ completed++;
118
+ onProgress?.(completed, items.length);
119
+ return { success: true, result };
120
+ } catch (error) {
121
+ const err = error instanceof Error ? error : new Error(String(error));
122
+ if (onError && !onError(err, item)) {
123
+ throw err; // Stop processing
124
+ }
125
+ completed++;
126
+ onProgress?.(completed, items.length);
127
+ return { success: false, error: err };
128
+ }
129
+ });
130
+ };
131
+
132
+ const promises = items.map((item) => processItem(item));
133
+ return await Promise.all(promises);
134
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Retry utilities with exponential backoff
3
+ */
4
+
5
+ export class RetryError extends Error {
6
+ constructor(
7
+ message: string,
8
+ public readonly attempts: number,
9
+ public readonly lastError: Error
10
+ ) {
11
+ super(message);
12
+ this.name = 'RetryError';
13
+ }
14
+ }
15
+
16
+ export interface RetryOptions {
17
+ /** Maximum number of retry attempts (default: 3) */
18
+ maxAttempts?: number;
19
+ /** Initial delay in milliseconds (default: 1000) */
20
+ initialDelay?: number;
21
+ /** Maximum delay in milliseconds (default: 30000) */
22
+ maxDelay?: number;
23
+ /** Backoff multiplier (default: 2) */
24
+ backoffFactor?: number;
25
+ /** Function to determine if error is retryable */
26
+ shouldRetry?: (error: Error, attempt: number) => boolean;
27
+ /** Callback on each retry */
28
+ onRetry?: (error: Error, attempt: number, nextDelay: number) => void;
29
+ }
30
+
31
+ /**
32
+ * Execute a function with automatic retry and exponential backoff
33
+ *
34
+ * Retries failed operations with increasing delays between attempts.
35
+ * Useful for handling transient failures in network requests.
36
+ *
37
+ * @param fn - Async function to execute
38
+ * @param options - Retry configuration options
39
+ * @returns Promise resolving to the function's return value
40
+ * @throws {RetryError} If all retry attempts fail
41
+ *
42
+ * @example
43
+ * ```typescript
44
+ * const data = await withRetry(
45
+ * () => fetch('https://api.example.com/data'),
46
+ * {
47
+ * maxAttempts: 3,
48
+ * initialDelay: 1000,
49
+ * shouldRetry: (err) => err.message.includes('timeout')
50
+ * }
51
+ * );
52
+ * ```
53
+ */
54
+ export async function withRetry<T>(
55
+ fn: () => Promise<T>,
56
+ options: RetryOptions = {}
57
+ ): Promise<T> {
58
+ const {
59
+ maxAttempts = 3,
60
+ initialDelay = 1000,
61
+ maxDelay = 30000,
62
+ backoffFactor = 2,
63
+ shouldRetry = () => true,
64
+ onRetry,
65
+ } = options;
66
+
67
+ let lastError: Error = new Error('No attempts made');
68
+
69
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
70
+ try {
71
+ return await fn();
72
+ } catch (error) {
73
+ lastError = error instanceof Error ? error : new Error(String(error));
74
+
75
+ if (attempt === maxAttempts || !shouldRetry(lastError, attempt)) {
76
+ break;
77
+ }
78
+
79
+ const delay = Math.min(
80
+ initialDelay * Math.pow(backoffFactor, attempt - 1),
81
+ maxDelay
82
+ );
83
+
84
+ onRetry?.(lastError, attempt, delay);
85
+
86
+ await new Promise((resolve) => setTimeout(resolve, delay));
87
+ }
88
+ }
89
+
90
+ throw new RetryError(
91
+ `Failed after ${maxAttempts} attempts: ${lastError.message}`,
92
+ maxAttempts,
93
+ lastError
94
+ );
95
+ }
96
+
97
+ /**
98
+ * Determine if a GitHub API error should be retried
99
+ *
100
+ * Implements retry logic specific to GitHub API errors:
101
+ * - Retries on rate limits (429) and server errors (5xx)
102
+ * - Retries on network errors
103
+ * - Does not retry on client errors (4xx except 429)
104
+ *
105
+ * @param error - The error that occurred
106
+ * @param _attempt - The attempt number (unused)
107
+ * @returns true if the error is retryable, false otherwise
108
+ *
109
+ * @example
110
+ * ```typescript
111
+ * const result = await withRetry(
112
+ * () => githubApiCall(),
113
+ * {
114
+ * shouldRetry: shouldRetryGitHubError
115
+ * }
116
+ * );
117
+ * ```
118
+ */
119
+ export function shouldRetryGitHubError(error: Error, _attempt: number): boolean {
120
+ const message = error.message.toLowerCase();
121
+
122
+ // Rate limit errors - always retry
123
+ if (message.includes('rate limit') || message.includes('429')) {
124
+ return true;
125
+ }
126
+
127
+ // Server errors (5xx) - retry
128
+ if (message.includes('500') || message.includes('502') ||
129
+ message.includes('503') || message.includes('504')) {
130
+ return true;
131
+ }
132
+
133
+ // Network errors - retry
134
+ if (message.includes('network') || message.includes('econnreset') ||
135
+ message.includes('etimedout') || message.includes('fetch failed')) {
136
+ return true;
137
+ }
138
+
139
+ // Client errors (4xx except 429) - don't retry
140
+ if (message.includes('401') || message.includes('403') ||
141
+ message.includes('404') || message.includes('422')) {
142
+ return false;
143
+ }
144
+
145
+ // Default: retry up to 3 times
146
+ return true;
147
+ }