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.
- package/.bunignore +7 -0
- package/.github/workflows/ci.yml +73 -0
- package/CLAUDE.md +111 -0
- package/CONTRIBUTING.md +145 -0
- package/README.md +168 -0
- package/bun.lock +267 -0
- package/bunfig.toml +15 -0
- package/cli +0 -0
- package/config/gitforest.example.yaml +94 -0
- package/docs/ai/IMPROVEMENT_PLAN.md +341 -0
- package/docs/ai/VERIFICATION_REPORT.md +87 -0
- package/docs/ai/architecture.md +169 -0
- package/docs/ai/checks/check-2025-12-02-tests.md +40 -0
- package/docs/ai/checks/check-2025-12-02.md +55 -0
- package/docs/ai/checks/test-verification-report.md +85 -0
- package/docs/ai/implementation-guide.md +776 -0
- package/docs/ai/research/gitty-codebase-analysis.md +221 -0
- package/docs/ai/tickets/GENERAL-sitrep.md +30 -0
- package/docs/ai/tickets/TASK-database-tests-sitrep.md +25 -0
- package/docs/ai/tickets/TASK-deprecated-functions-sitrep.md +28 -0
- package/docs/ai/tickets/TASK-detail-modal-sitrep.md +28 -0
- package/docs/ai/tickets/TASK-filter-overlay-sitrep.md +24 -0
- package/docs/ai/tickets/TASK-github-service-sitrep.md +32 -0
- package/docs/ai/tickets/TASK-github-token-sitrep.md +51 -0
- package/docs/ai/tickets/TASK-hascommits-sitrep.md +35 -0
- package/docs/ai/tickets/TASK-keybindings-sitrep.md +26 -0
- package/docs/ai/tickets/TASK-layout-sitrep.md +25 -0
- package/docs/ai/tickets/TASK-markdown-sitrep.md +28 -0
- package/docs/ai/tickets/TASK-project-item-sitrep.md +79 -0
- package/docs/ai/tickets/TASK-sitrep.md +28 -0
- package/docs/ai/tickets/TASK-state-sitrep.md +26 -0
- package/docs/ai/tickets/TASK-types-sitrep.md +25 -0
- package/docs/ai/tickets/TASK-unified-item-fix-sitrep.md +26 -0
- package/docs/ai/tickets/TKT-001-sitrep.md +24 -0
- package/docs/ai/tickets/TKT-002-sitrep.md +25 -0
- package/docs/ai/tickets/TKT-003-git-service-refactoring-complete.md +46 -0
- package/docs/ai/tickets/TKT-003-git-service-refactoring-plan.md +135 -0
- package/docs/ai/tickets/TKT-003-sitrep.md +26 -0
- package/docs/ai/tickets/TKT-004-sitrep.md +27 -0
- package/docs/ai/tickets/TKT-005-sitrep.md +25 -0
- package/docs/ai/tickets/TKT-006-sitrep.md +26 -0
- package/docs/ai/tickets/TKT-007-sitrep.md +30 -0
- package/docs/ai/tickets/TKT-008-sitrep.md +32 -0
- package/docs/ai/tickets/TKT-009-sitrep.md +27 -0
- package/docs/ai/tickets/TKT-010-sitrep.md +27 -0
- package/docs/ai/tickets/TKT-011-sitrep.md +26 -0
- package/docs/ai/tickets/TKT-012-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-actions-sitrep.md +28 -0
- package/docs/ai/tickets/sitreps/TASK-actions-test-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-app-integration-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-background-fetch-sitrep.md +24 -0
- package/docs/ai/tickets/sitreps/TASK-background-fetch-test-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-batch-tests-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-bun-test-sitrep.md +26 -0
- package/docs/ai/tickets/sitreps/TASK-cache-tests-sitrep.md +30 -0
- package/docs/ai/tickets/sitreps/TASK-cli-tests-sitrep.md +28 -0
- package/docs/ai/tickets/sitreps/TASK-clone-error-handling-sitrep.md +26 -0
- package/docs/ai/tickets/sitreps/TASK-commands-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-component-tests-1-sitrep.md +30 -0
- package/docs/ai/tickets/sitreps/TASK-configloader-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-confirm-dialog-test-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-coverage-sitrep.md +95 -0
- package/docs/ai/tickets/sitreps/TASK-database-tests-summary.md +61 -0
- package/docs/ai/tickets/sitreps/TASK-error-boundary-sitrep.md +30 -0
- package/docs/ai/tickets/sitreps/TASK-error-tests-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-errors-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-extract-reducer-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-filter-overlay-test-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-final-verification-sitrep.md +28 -0
- package/docs/ai/tickets/sitreps/TASK-fix-all-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-fix-hooks-sitrep.md +26 -0
- package/docs/ai/tickets/sitreps/TASK-fix-remaining-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-fix-test-failures-sitrep.md +26 -0
- package/docs/ai/tickets/sitreps/TASK-fix-tests-sitrep.md +24 -0
- package/docs/ai/tickets/sitreps/TASK-formatters-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-git-timeouts-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-github-cache-test-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-githubcli-tests-sitrep.md +24 -0
- package/docs/ai/tickets/sitreps/TASK-gitstatus-tests-sitrep.md +24 -0
- package/docs/ai/tickets/sitreps/TASK-hooks-isolation-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-keybindings-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-layout-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-mock-factories-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-modal-tests-sitrep.md +32 -0
- package/docs/ai/tickets/sitreps/TASK-processbatch-fix-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-projectlist-tests-sitrep.md +30 -0
- package/docs/ai/tickets/sitreps/TASK-projectutils-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-scanner-tests-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-select-all-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-shell-error-handling-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-store-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-test-fixes-sitrep.md +26 -0
- package/docs/ai/tickets/sitreps/TASK-test-summary-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-test-verification-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-testsuite-sitrep.md +75 -0
- package/docs/ai/tickets/sitreps/TASK-unified-reducer-tests-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-unified-repos-test-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-unified-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-useprojects-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-utility-tests-sitrep.md +32 -0
- package/docs/ai/tickets/sitreps/TKT-003-git-service-refactoring-sitrep.md +64 -0
- package/docs/ai/tkt-001-fix-database-error.md +217 -0
- package/docs/ai/ui-enhancement-plan.md +562 -0
- package/package.json +50 -0
- package/src/app.tsx +43 -0
- package/src/cli/config.ts +94 -0
- package/src/cli/formatters.ts +632 -0
- package/src/cli/index.ts +583 -0
- package/src/components/CloneDialog.tsx +137 -0
- package/src/components/ColumnHeader.tsx +128 -0
- package/src/components/CommandPalette.tsx +120 -0
- package/src/components/ConfirmDialog.tsx +105 -0
- package/src/components/ErrorBoundary.tsx +128 -0
- package/src/components/FilterBar.tsx +71 -0
- package/src/components/FilterOptionsOverlay.tsx +131 -0
- package/src/components/HelpOverlay.tsx +120 -0
- package/src/components/Layout.tsx +379 -0
- package/src/components/MarkdownRenderer.tsx +127 -0
- package/src/components/ProgressBar.tsx +53 -0
- package/src/components/ProjectItem.tsx +143 -0
- package/src/components/ProjectList.tsx +90 -0
- package/src/components/RepoDetailModal.tsx +367 -0
- package/src/components/StatusBar.tsx +188 -0
- package/src/components/UnifiedProjectItem.tsx +436 -0
- package/src/components/ViewModeIndicator.tsx +37 -0
- package/src/components/onboarding/CompleteStep.tsx +82 -0
- package/src/components/onboarding/DirectoriesStep.test.tsx +52 -0
- package/src/components/onboarding/DirectoriesStep.tsx +847 -0
- package/src/components/onboarding/DirectoriesStep.unit.test.ts +345 -0
- package/src/components/onboarding/GitHubAuthStep.tsx +268 -0
- package/src/components/onboarding/OnboardingWizard.tsx +130 -0
- package/src/components/onboarding/WelcomeStep.tsx +69 -0
- package/src/config/loader.ts +263 -0
- package/src/config/onboarding.ts +67 -0
- package/src/constants.ts +96 -0
- package/src/db/index.ts +147 -0
- package/src/db/schema.ts +70 -0
- package/src/git/commands.ts +283 -0
- package/src/git/index.ts +2 -0
- package/src/git/operations.ts +93 -0
- package/src/git/service.ts +539 -0
- package/src/git/status.ts +84 -0
- package/src/git/types.ts +5 -0
- package/src/github/auth.ts +311 -0
- package/src/github/cache.ts +231 -0
- package/src/github/cli.ts +22 -0
- package/src/github/unified.ts +415 -0
- package/src/hooks/useBackgroundFetch.ts +76 -0
- package/src/hooks/useConfirmDialogActions.ts +120 -0
- package/src/hooks/useKeyBindings.ts +656 -0
- package/src/hooks/useProjects.ts +47 -0
- package/src/hooks/useUnifiedRepos.ts +317 -0
- package/src/index.tsx +494 -0
- package/src/operations/batch.ts +280 -0
- package/src/operations/commands.ts +140 -0
- package/src/operations/index.ts +37 -0
- package/src/scanner/index.ts +424 -0
- package/src/scanner/markers.ts +43 -0
- package/src/scanner/submodules.ts +61 -0
- package/src/services/git.ts +484 -0
- package/src/services/github.ts +676 -0
- package/src/services/index.ts +28 -0
- package/src/services/types.ts +99 -0
- package/src/state/actions.ts +175 -0
- package/src/state/reducer.ts +294 -0
- package/src/state/store.tsx +216 -0
- package/src/state/types.ts +8 -0
- package/src/types/index.ts +383 -0
- package/src/ui/theme.ts +44 -0
- package/src/utils/array.ts +14 -0
- package/src/utils/debug.ts +38 -0
- package/src/utils/errors.ts +17 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/markdown.ts +230 -0
- package/src/utils/project-utils.ts +129 -0
- package/src/utils/rate-limiter.ts +134 -0
- package/src/utils/retry.ts +147 -0
- package/src/utils/timeout.ts +56 -0
- package/test/integration/app.isolated.tsx +240 -0
- package/test/integration/cli-commands.test.ts +287 -0
- package/test/integration/cli-validation.test.ts +264 -0
- package/test/integration/git-operations.test.ts +218 -0
- package/test/integration/scanner.test.ts +228 -0
- package/test/preload.ts +18 -0
- package/test/unit/cli/commands.test.ts +13 -0
- package/test/unit/cli/formatters.test.ts +1116 -0
- package/test/unit/cli/github-commands.test.ts +12 -0
- package/test/unit/components/CloneDialog.test.tsx +240 -0
- package/test/unit/components/ColumnHeader.test.tsx +128 -0
- package/test/unit/components/CommandPalette.test.tsx +355 -0
- package/test/unit/components/ConfirmDialog.test.tsx +111 -0
- package/test/unit/components/ErrorBoundary.test.tsx +139 -0
- package/test/unit/components/FilterBar.test.tsx +43 -0
- package/test/unit/components/FilterOptionsOverlay.test.tsx +197 -0
- package/test/unit/components/HelpOverlay.test.tsx +90 -0
- package/test/unit/components/Layout.test.tsx +328 -0
- package/test/unit/components/MarkdownRenderer.test.tsx +45 -0
- package/test/unit/components/ProgressBar.test.tsx +138 -0
- package/test/unit/components/ProjectItem.test.tsx +182 -0
- package/test/unit/components/ProjectList.test.tsx +311 -0
- package/test/unit/components/RepoDetailModal.test.tsx +445 -0
- package/test/unit/components/StatusBar.test.tsx +112 -0
- package/test/unit/components/UnifiedProjectItem.test.tsx +618 -0
- package/test/unit/components/ViewModeIndicator.test.tsx +137 -0
- package/test/unit/components/test-utils.tsx +63 -0
- package/test/unit/config/loader.test.ts +692 -0
- package/test/unit/db/database.test.ts +978 -0
- package/test/unit/db/index.test.ts +314 -0
- package/test/unit/fixtures/setup.ts +186 -0
- package/test/unit/git/commands-untested.test.ts +205 -0
- package/test/unit/git/commands.test.ts +269 -0
- package/test/unit/git/operations.test.ts +322 -0
- package/test/unit/git/status.test.ts +219 -0
- package/test/unit/github/auth.test.ts +317 -0
- package/test/unit/github/cache.test.ts +1028 -0
- package/test/unit/github/cli.test.ts +135 -0
- package/test/unit/github/unified.test.ts +1201 -0
- package/test/unit/graceful-shutdown.test.ts +83 -0
- package/test/unit/hooks/useBackgroundFetch.test.tsx +239 -0
- package/test/unit/hooks/useConfirmDialogActions.test.tsx +81 -0
- package/test/unit/hooks/useKeyBindings.isolated.ts +715 -0
- package/test/unit/hooks/useProjects.test.tsx +186 -0
- package/test/unit/hooks/useUnifiedRepos-simple.test.tsx +115 -0
- package/test/unit/hooks/useUnifiedRepos.test.tsx +177 -0
- package/test/unit/mocks/config.ts +109 -0
- package/test/unit/mocks/git-service.ts +274 -0
- package/test/unit/mocks/github-service.ts +250 -0
- package/test/unit/mocks/index.ts +72 -0
- package/test/unit/mocks/project.ts +148 -0
- package/test/unit/mocks/state-mocks.ts +187 -0
- package/test/unit/mocks/unified.ts +169 -0
- package/test/unit/operations/batch.test.ts +216 -0
- package/test/unit/operations/commands.test.ts +550 -0
- package/test/unit/scanner/errors.test.ts +297 -0
- package/test/unit/scanner/index.test.ts +1011 -0
- package/test/unit/scanner/markers.test.ts +150 -0
- package/test/unit/scanner/submodules.test.ts +99 -0
- package/test/unit/services/git-errors.test.ts +190 -0
- package/test/unit/services/git.test.ts +442 -0
- package/test/unit/services/github-errors.test.ts +293 -0
- package/test/unit/services/github.test.ts +200 -0
- package/test/unit/state/actions.test.ts +217 -0
- package/test/unit/state/reducer.test.ts +745 -0
- package/test/unit/state/store.test.tsx +711 -0
- package/test/unit/types/commands.test.ts +220 -0
- package/test/unit/types/schema.test.ts +179 -0
- package/test/unit/utils/array.test.ts +73 -0
- package/test/unit/utils/debug.test.ts +23 -0
- package/test/unit/utils/errors.test.ts +295 -0
- package/test/unit/utils/markdown.test.ts +163 -0
- package/test/unit/utils/project-utils.test.ts +756 -0
- package/test/unit/utils/rate-limiter.test.ts +256 -0
- package/test/unit/utils/retry.test.ts +165 -0
- package/test/unit/utils/strip-ansi.ts +13 -0
- package/test/unit/utils/timeout.test.ts +93 -0
- 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
|
+
}
|