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,120 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import type { CommandConfig } from "../types/index.ts";
|
|
3
|
+
|
|
4
|
+
interface HelpOverlayProps {
|
|
5
|
+
commands?: CommandConfig[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const HELP_SECTIONS = [
|
|
9
|
+
{
|
|
10
|
+
title: "Navigation",
|
|
11
|
+
keys: [
|
|
12
|
+
{ key: "j / ↓", desc: "Move down" },
|
|
13
|
+
{ key: "k / ↑", desc: "Move up" },
|
|
14
|
+
{ key: "g", desc: "Go to top" },
|
|
15
|
+
{ key: "G", desc: "Go to bottom" },
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
title: "Selection",
|
|
20
|
+
keys: [
|
|
21
|
+
{ key: "space", desc: "Toggle selection" },
|
|
22
|
+
{ key: "a", desc: "Select all / deselect all" },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
title: "View Mode",
|
|
27
|
+
keys: [
|
|
28
|
+
{ key: "Tab", desc: "Cycle view (local/github/all)" },
|
|
29
|
+
{ key: "0", desc: "Show all" },
|
|
30
|
+
{ key: "1", desc: "Show dirty" },
|
|
31
|
+
{ key: "2", desc: "Show unpushed" },
|
|
32
|
+
{ key: "3", desc: "Show no-remote" },
|
|
33
|
+
{ key: "4", desc: "Show GitHub-only" },
|
|
34
|
+
{ key: "5", desc: "Show local-only" },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
title: "Git Operations",
|
|
39
|
+
keys: [
|
|
40
|
+
{ key: "p", desc: "Push selected" },
|
|
41
|
+
{ key: "P", desc: "Pull all repos" },
|
|
42
|
+
{ key: "f", desc: "Fetch all remotes" },
|
|
43
|
+
{ key: "i", desc: "Init git in selected" },
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
title: "GitHub",
|
|
48
|
+
keys: [
|
|
49
|
+
{ key: "D", desc: "Clone GitHub repos" },
|
|
50
|
+
{ key: "c", desc: "Create GitHub repo" },
|
|
51
|
+
{ key: "C", desc: "Setup (init + create + push)" },
|
|
52
|
+
{ key: "A", desc: "Archive GitHub repo" },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
title: "General",
|
|
57
|
+
keys: [
|
|
58
|
+
{ key: "/", desc: "Filter projects" },
|
|
59
|
+
{ key: "s", desc: "Cycle sort field" },
|
|
60
|
+
{ key: "x", desc: "Command palette" },
|
|
61
|
+
{ key: "r", desc: "Refresh" },
|
|
62
|
+
{ key: "?", desc: "Toggle help" },
|
|
63
|
+
{ key: "Esc", desc: "Cancel / exit mode" },
|
|
64
|
+
{ key: "q", desc: "Quit" },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
export function HelpOverlay({ commands = [] }: HelpOverlayProps) {
|
|
70
|
+
// Build sections including custom commands if configured
|
|
71
|
+
const sections = [...HELP_SECTIONS];
|
|
72
|
+
|
|
73
|
+
if (commands.length > 0) {
|
|
74
|
+
sections.push({
|
|
75
|
+
title: "Custom Commands",
|
|
76
|
+
keys: commands.map((cmd) => ({
|
|
77
|
+
key: cmd.key,
|
|
78
|
+
desc: cmd.name,
|
|
79
|
+
})),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<Box
|
|
85
|
+
flexDirection="column"
|
|
86
|
+
borderStyle="round"
|
|
87
|
+
borderColor="cyan"
|
|
88
|
+
paddingX={2}
|
|
89
|
+
paddingY={1}
|
|
90
|
+
>
|
|
91
|
+
<Box marginBottom={1}>
|
|
92
|
+
<Text bold color="cyan">
|
|
93
|
+
Keyboard Shortcuts
|
|
94
|
+
</Text>
|
|
95
|
+
</Box>
|
|
96
|
+
|
|
97
|
+
<Box flexDirection="row" flexWrap="wrap" gap={4}>
|
|
98
|
+
{sections.map((section) => (
|
|
99
|
+
<Box key={section.title} flexDirection="column" width={30}>
|
|
100
|
+
<Text bold underline>
|
|
101
|
+
{section.title}
|
|
102
|
+
</Text>
|
|
103
|
+
{section.keys.map(({ key, desc }) => (
|
|
104
|
+
<Box key={key} gap={1}>
|
|
105
|
+
<Box width={14}>
|
|
106
|
+
<Text color="yellow">{key}</Text>
|
|
107
|
+
</Box>
|
|
108
|
+
<Text>{desc}</Text>
|
|
109
|
+
</Box>
|
|
110
|
+
))}
|
|
111
|
+
</Box>
|
|
112
|
+
))}
|
|
113
|
+
</Box>
|
|
114
|
+
|
|
115
|
+
<Box marginTop={1}>
|
|
116
|
+
<Text dimColor>Press any key to close</Text>
|
|
117
|
+
</Box>
|
|
118
|
+
</Box>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { Box, Text, useStdout } from "ink";
|
|
2
|
+
import { StatusBar } from "./StatusBar.tsx";
|
|
3
|
+
import { FilterBar } from "./FilterBar.tsx";
|
|
4
|
+
import { ProjectList } from "./ProjectList.tsx";
|
|
5
|
+
import { HelpOverlay } from "./HelpOverlay.tsx";
|
|
6
|
+
import { ConfirmDialog } from "./ConfirmDialog.tsx";
|
|
7
|
+
import { CloneDialog } from "./CloneDialog.tsx";
|
|
8
|
+
import { RepoDetailModal } from "./RepoDetailModal.tsx";
|
|
9
|
+
import { CommandPalette } from "./CommandPalette.tsx";
|
|
10
|
+
import { useStore, useFilteredUnifiedRepos, useSelectedUnifiedRepos } from "../state/store.tsx";
|
|
11
|
+
import { useConfirmDialogActions } from "../hooks/useConfirmDialogActions.ts";
|
|
12
|
+
import { startAction, endAction, setMessage } from "../state/actions.ts";
|
|
13
|
+
import { executeCommand } from "../operations/commands.ts";
|
|
14
|
+
import { errorToString } from "../utils/errors.ts";
|
|
15
|
+
import { UI } from "../constants.ts";
|
|
16
|
+
import type { GitforestConfig, CommandConfig } from "../types/index.ts";
|
|
17
|
+
|
|
18
|
+
interface LayoutProps {
|
|
19
|
+
config: GitforestConfig;
|
|
20
|
+
onRefresh: () => Promise<void>;
|
|
21
|
+
onClone?: (repos: any[], targetDir: string, useSSH: boolean) => Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function Layout({ config, onRefresh, onClone }: LayoutProps) {
|
|
25
|
+
const { state, dispatch } = useStore();
|
|
26
|
+
const { mode, confirmDialog, cloneDialog } = state;
|
|
27
|
+
const filteredRepos = useFilteredUnifiedRepos();
|
|
28
|
+
const selectedRepos = useSelectedUnifiedRepos();
|
|
29
|
+
const { stdout } = useStdout();
|
|
30
|
+
|
|
31
|
+
const { handleConfirm, handleCancel } = useConfirmDialogActions({
|
|
32
|
+
config,
|
|
33
|
+
onRefresh,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Calculate available height
|
|
37
|
+
const terminalHeight = stdout?.rows ?? UI.DEFAULT_TERMINAL_HEIGHT;
|
|
38
|
+
const listHeight = terminalHeight - UI.LAYOUT_OVERHEAD;
|
|
39
|
+
|
|
40
|
+
// Calculate stats for status bar
|
|
41
|
+
const dirtyCount = filteredRepos.filter((r) => r.local?.status?.isDirty).length;
|
|
42
|
+
const unpushedCount = filteredRepos.filter((r) => r.local?.status?.isAhead).length;
|
|
43
|
+
const localOnlyCount = filteredRepos.filter((r) => r.source === "local").length;
|
|
44
|
+
const githubOnlyCount = filteredRepos.filter((r) => r.source === "github").length;
|
|
45
|
+
const syncedCount = filteredRepos.filter((r) => r.source === "both").length;
|
|
46
|
+
|
|
47
|
+
// Clone dialog handlers
|
|
48
|
+
const handleCloneConfirm = async (targetDir: string, useSSH: boolean) => {
|
|
49
|
+
dispatch({ type: "HIDE_CLONE_DIALOG" });
|
|
50
|
+
if (onClone && cloneDialog) {
|
|
51
|
+
await onClone(cloneDialog.repos, targetDir, useSSH);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const handleCloneCancel = () => {
|
|
56
|
+
dispatch({ type: "HIDE_CLONE_DIALOG" });
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const handleCloneSelectDir = (index: number) => {
|
|
60
|
+
dispatch({ type: "UPDATE_CLONE_DIALOG", payload: { selectedDirIndex: index } });
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const handleCloneToggleSSH = () => {
|
|
64
|
+
if (cloneDialog) {
|
|
65
|
+
dispatch({ type: "UPDATE_CLONE_DIALOG", payload: { useSSH: !cloneDialog.useSSH } });
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Show help overlay
|
|
70
|
+
if (mode === "help") {
|
|
71
|
+
return (
|
|
72
|
+
<Box flexDirection="column" height={terminalHeight}>
|
|
73
|
+
<HelpOverlay commands={config.commands} />
|
|
74
|
+
</Box>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Show command palette
|
|
79
|
+
if (mode === "command-palette") {
|
|
80
|
+
return (
|
|
81
|
+
<Box flexDirection="column" height={terminalHeight} justifyContent="center" alignItems="center">
|
|
82
|
+
<CommandPalette
|
|
83
|
+
commands={config.commands}
|
|
84
|
+
selectedRepos={selectedRepos}
|
|
85
|
+
onClose={() => dispatch({ type: "SET_MODE", payload: "normal" })}
|
|
86
|
+
/>
|
|
87
|
+
</Box>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Show confirm dialog
|
|
92
|
+
if (mode === "confirm" && confirmDialog) {
|
|
93
|
+
return (
|
|
94
|
+
<Box flexDirection="column" height={terminalHeight} justifyContent="center" alignItems="center">
|
|
95
|
+
<ConfirmDialog
|
|
96
|
+
title={confirmDialog.title}
|
|
97
|
+
message={confirmDialog.message}
|
|
98
|
+
items={confirmDialog.items}
|
|
99
|
+
showVisibilityToggle={confirmDialog.showVisibilityToggle}
|
|
100
|
+
defaultVisibility={config.github.defaultVisibility}
|
|
101
|
+
onConfirm={handleConfirm}
|
|
102
|
+
onCancel={handleCancel}
|
|
103
|
+
/>
|
|
104
|
+
</Box>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Show clone dialog
|
|
109
|
+
if (mode === "clone" && cloneDialog) {
|
|
110
|
+
return (
|
|
111
|
+
<Box flexDirection="column" height={terminalHeight} justifyContent="center" alignItems="center">
|
|
112
|
+
<CloneDialog
|
|
113
|
+
repos={cloneDialog.repos}
|
|
114
|
+
directories={cloneDialog.directories}
|
|
115
|
+
selectedDirIndex={cloneDialog.selectedDirIndex}
|
|
116
|
+
useSSH={cloneDialog.useSSH}
|
|
117
|
+
onConfirm={handleCloneConfirm}
|
|
118
|
+
onCancel={handleCloneCancel}
|
|
119
|
+
onSelectDir={handleCloneSelectDir}
|
|
120
|
+
onToggleSSH={handleCloneToggleSSH}
|
|
121
|
+
/>
|
|
122
|
+
</Box>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Show detail modal
|
|
127
|
+
if (mode === "detail" && state.detailModal) {
|
|
128
|
+
const handleDetailClose = () => {
|
|
129
|
+
dispatch({ type: "HIDE_DETAIL_MODAL" });
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const handleDetailAction = async (action: string) => {
|
|
133
|
+
const repo = state.detailModal?.repo;
|
|
134
|
+
if (!repo) return;
|
|
135
|
+
|
|
136
|
+
switch (action) {
|
|
137
|
+
case "clone": {
|
|
138
|
+
dispatch({
|
|
139
|
+
type: "SHOW_CLONE_DIALOG",
|
|
140
|
+
payload: {
|
|
141
|
+
repos: [repo],
|
|
142
|
+
directories: config.directories,
|
|
143
|
+
selectedDirIndex: 0,
|
|
144
|
+
useSSH: true,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
case "primary": {
|
|
150
|
+
if (repo.localPath) {
|
|
151
|
+
// Fallthrough to editor logic
|
|
152
|
+
await handleDetailAction("editor");
|
|
153
|
+
} else if (repo.github?.htmlUrl) {
|
|
154
|
+
await handleDetailAction("browser");
|
|
155
|
+
}
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
case "push":
|
|
159
|
+
if (repo.local && repo.local.status?.isAhead) {
|
|
160
|
+
dispatch(startAction("Pushing"));
|
|
161
|
+
// Push logic would be implemented here
|
|
162
|
+
dispatch(endAction());
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
case "pull":
|
|
166
|
+
if (repo.local && repo.local.status?.hasRemote) {
|
|
167
|
+
dispatch(startAction("Pulling"));
|
|
168
|
+
// Pull logic would be implemented here
|
|
169
|
+
dispatch(endAction());
|
|
170
|
+
}
|
|
171
|
+
break;
|
|
172
|
+
case "fetch":
|
|
173
|
+
if (repo.local && repo.local.status?.hasRemote) {
|
|
174
|
+
dispatch(startAction("Fetching"));
|
|
175
|
+
// Fetch logic would be implemented here
|
|
176
|
+
dispatch(endAction());
|
|
177
|
+
}
|
|
178
|
+
break;
|
|
179
|
+
case "browser":
|
|
180
|
+
if (repo.github?.htmlUrl) {
|
|
181
|
+
try {
|
|
182
|
+
Bun.spawn(["open", repo.github.htmlUrl], {
|
|
183
|
+
stdout: "ignore",
|
|
184
|
+
stderr: "ignore",
|
|
185
|
+
});
|
|
186
|
+
} catch (error) {
|
|
187
|
+
dispatch(setMessage(`Failed to open browser: ${errorToString(error)}`));
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
dispatch(setMessage("No GitHub URL available for this repository"));
|
|
191
|
+
}
|
|
192
|
+
break;
|
|
193
|
+
case "editor":
|
|
194
|
+
if (repo.localPath) {
|
|
195
|
+
// Resolve editor: Project > Global > Env > Default
|
|
196
|
+
let editor = config.editor;
|
|
197
|
+
|
|
198
|
+
// Check for project-specific editor
|
|
199
|
+
if (repo.localPath) {
|
|
200
|
+
const matchedDir = config.directories.find(d =>
|
|
201
|
+
repo.localPath!.startsWith(d.path.replace(/^~/, process.env.HOME || ""))
|
|
202
|
+
);
|
|
203
|
+
if (matchedDir?.editor) {
|
|
204
|
+
editor = matchedDir.editor;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!editor) {
|
|
209
|
+
editor = process.env.EDITOR || "code";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Split command and args once
|
|
213
|
+
const parts = editor.split(" ");
|
|
214
|
+
const cmd = parts[0]!;
|
|
215
|
+
const args = parts.slice(1);
|
|
216
|
+
|
|
217
|
+
// Check if it's a known terminal editor
|
|
218
|
+
const terminalEditors = ["vim", "nvim", "nano", "vi", "emacs", "hx", "helix"];
|
|
219
|
+
const isTerminal = terminalEditors.includes(cmd);
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
if (isTerminal) {
|
|
223
|
+
// Suspends Ink's raw mode to allow the editor to take over
|
|
224
|
+
if (process.stdin.setRawMode) {
|
|
225
|
+
process.stdin.setRawMode(false);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Spawn with inherit to take over terminal
|
|
229
|
+
// IMPORTANT: Use split cmd and args
|
|
230
|
+
const proc = Bun.spawn([cmd, ...args, repo.localPath], {
|
|
231
|
+
stdin: "inherit",
|
|
232
|
+
stdout: "inherit",
|
|
233
|
+
stderr: "inherit",
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
await proc.exited;
|
|
237
|
+
|
|
238
|
+
// Resume raw mode
|
|
239
|
+
if (process.stdin.setRawMode) {
|
|
240
|
+
process.stdin.setRawMode(true);
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
// GUI Editor
|
|
244
|
+
|
|
245
|
+
// Use 'open' command for macOS GUI editors (VS Code, Cursor) specific optimization
|
|
246
|
+
if (process.platform === "darwin" && (cmd === "code" || cmd === "cursor")) {
|
|
247
|
+
const appName = cmd === "code" ? "Visual Studio Code" : "Cursor";
|
|
248
|
+
|
|
249
|
+
const openArgs = ["open", "-a", appName, repo.localPath];
|
|
250
|
+
openArgs.push("--args", "-n"); // Force new window
|
|
251
|
+
|
|
252
|
+
const subprocess = Bun.spawn(openArgs, {
|
|
253
|
+
stdin: "ignore",
|
|
254
|
+
stdout: "ignore",
|
|
255
|
+
stderr: "ignore",
|
|
256
|
+
});
|
|
257
|
+
subprocess.unref();
|
|
258
|
+
|
|
259
|
+
} else {
|
|
260
|
+
// Fallback for other GUI editors
|
|
261
|
+
|
|
262
|
+
// Auto-inject -n for code/cursor if not using 'open' strategy or on other platforms
|
|
263
|
+
if ((cmd === "code" || cmd === "cursor") && !args.includes("-n") && !args.includes("--new-window")) {
|
|
264
|
+
args.push("-n");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const subprocess = Bun.spawn([cmd, ...args, repo.localPath], {
|
|
268
|
+
stdin: "ignore",
|
|
269
|
+
stdout: "ignore",
|
|
270
|
+
stderr: "ignore",
|
|
271
|
+
});
|
|
272
|
+
subprocess.unref();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
} catch (error) {
|
|
276
|
+
dispatch(setMessage(`Failed to open editor: ${errorToString(error)}`));
|
|
277
|
+
// Ensure raw mode is back if we failed mid-flight
|
|
278
|
+
if (process.stdin.setRawMode) process.stdin.setRawMode(true);
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
dispatch(setMessage("No local path available for this repository"));
|
|
282
|
+
}
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const handleDetailScroll = (offset: number) => {
|
|
288
|
+
dispatch({ type: "UPDATE_DETAIL_MODAL", payload: { readmeScrollOffset: offset } });
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const handleDetailCommand = async (command: CommandConfig) => {
|
|
292
|
+
const repo = state.detailModal?.repo;
|
|
293
|
+
if (!repo) return;
|
|
294
|
+
|
|
295
|
+
const projectPath = repo.localPath || repo.local?.path;
|
|
296
|
+
if (!projectPath) {
|
|
297
|
+
dispatch(setMessage("No local path for this repo"));
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
dispatch(startAction(`Running: ${command.name}`));
|
|
302
|
+
try {
|
|
303
|
+
const result = await executeCommand(command, projectPath);
|
|
304
|
+
dispatch(endAction());
|
|
305
|
+
if (result.success) {
|
|
306
|
+
const shortOutput = result.output && result.output.length > 50
|
|
307
|
+
? result.output.slice(0, 50) + "..."
|
|
308
|
+
: result.output || "Done";
|
|
309
|
+
dispatch(setMessage(`${command.name}: ${shortOutput}`));
|
|
310
|
+
} else {
|
|
311
|
+
dispatch(setMessage(`${command.name} failed: ${result.error}`));
|
|
312
|
+
}
|
|
313
|
+
} catch (error) {
|
|
314
|
+
dispatch(endAction());
|
|
315
|
+
dispatch(setMessage(`${command.name} failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
<Box flexDirection="column" height={terminalHeight}>
|
|
321
|
+
<RepoDetailModal
|
|
322
|
+
repo={state.detailModal.repo}
|
|
323
|
+
readmeContent={state.detailModal.readmeContent}
|
|
324
|
+
readmeLoading={state.detailModal.readmeLoading}
|
|
325
|
+
readmeError={state.detailModal.readmeError}
|
|
326
|
+
scrollOffset={state.detailModal.readmeScrollOffset}
|
|
327
|
+
commands={config.commands}
|
|
328
|
+
onClose={handleDetailClose}
|
|
329
|
+
onAction={handleDetailAction}
|
|
330
|
+
onScroll={handleDetailScroll}
|
|
331
|
+
onCommand={handleDetailCommand}
|
|
332
|
+
/>
|
|
333
|
+
</Box>
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Show filter options overlay
|
|
338
|
+
if (mode === "filter-options") {
|
|
339
|
+
return (
|
|
340
|
+
<Box flexDirection="column" height={terminalHeight}>
|
|
341
|
+
<Text>Filter Options Overlay - To be implemented</Text>
|
|
342
|
+
</Box>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return (
|
|
347
|
+
<Box flexDirection="column" height={terminalHeight}>
|
|
348
|
+
{/* Header */}
|
|
349
|
+
<Box borderStyle="single" borderBottom borderColor="gray" paddingX={1}>
|
|
350
|
+
<Text bold color="cyan">
|
|
351
|
+
gitforest
|
|
352
|
+
</Text>
|
|
353
|
+
<Text> - Git Repository Manager</Text>
|
|
354
|
+
</Box>
|
|
355
|
+
|
|
356
|
+
{/* Filter bar */}
|
|
357
|
+
<Box paddingX={1} paddingY={0}>
|
|
358
|
+
<FilterBar />
|
|
359
|
+
</Box>
|
|
360
|
+
|
|
361
|
+
{/* Project list */}
|
|
362
|
+
<Box flexGrow={1} flexDirection="column" paddingX={1}>
|
|
363
|
+
<ProjectList height={listHeight} />
|
|
364
|
+
</Box>
|
|
365
|
+
|
|
366
|
+
{/* Status bar */}
|
|
367
|
+
<Box borderStyle="single" borderTop borderColor="gray" paddingX={1}>
|
|
368
|
+
<StatusBar
|
|
369
|
+
projectCount={filteredRepos.length}
|
|
370
|
+
dirtyCount={dirtyCount}
|
|
371
|
+
unpushedCount={unpushedCount}
|
|
372
|
+
localOnlyCount={localOnlyCount}
|
|
373
|
+
githubOnlyCount={githubOnlyCount}
|
|
374
|
+
syncedCount={syncedCount}
|
|
375
|
+
/>
|
|
376
|
+
</Box>
|
|
377
|
+
</Box>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import { parseMarkdown, truncateMarkdown, type MarkdownNode } from '../utils/markdown';
|
|
3
|
+
|
|
4
|
+
interface MarkdownRendererProps {
|
|
5
|
+
content: string;
|
|
6
|
+
maxHeight?: number;
|
|
7
|
+
scrollOffset?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function MarkdownRenderer({ content, maxHeight, scrollOffset = 0 }: MarkdownRendererProps): JSX.Element {
|
|
11
|
+
const nodes = parseMarkdown(content);
|
|
12
|
+
const displayNodes = maxHeight ? truncateMarkdown(nodes, maxHeight) : nodes;
|
|
13
|
+
|
|
14
|
+
let lineCount = 0;
|
|
15
|
+
|
|
16
|
+
function renderNode(node: MarkdownNode, indentLevel = 0): JSX.Element[] {
|
|
17
|
+
const indent = ' '.repeat(indentLevel);
|
|
18
|
+
|
|
19
|
+
switch (node.type) {
|
|
20
|
+
case 'heading':
|
|
21
|
+
const headingText = node.content || '';
|
|
22
|
+
if (node.level === 1) {
|
|
23
|
+
return [
|
|
24
|
+
<Text key={`h1-${lineCount++}`} bold color="cyan">
|
|
25
|
+
{headingText}
|
|
26
|
+
</Text>
|
|
27
|
+
];
|
|
28
|
+
} else if (node.level === 2) {
|
|
29
|
+
return [
|
|
30
|
+
<Text key={`h2-${lineCount++}`} bold color="blue">
|
|
31
|
+
{headingText}
|
|
32
|
+
</Text>
|
|
33
|
+
];
|
|
34
|
+
} else {
|
|
35
|
+
return [
|
|
36
|
+
<Text key={`h${node.level}-${lineCount++}`} bold>
|
|
37
|
+
{headingText}
|
|
38
|
+
</Text>
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
case 'paragraph':
|
|
43
|
+
return [
|
|
44
|
+
<Text key={`p-${lineCount++}`}>
|
|
45
|
+
{node.children?.map((child, idx) => renderInline(child, `${lineCount}-${idx}`))}
|
|
46
|
+
</Text>
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
case 'code':
|
|
50
|
+
return [
|
|
51
|
+
<Text key={`code-${lineCount++}`} inverse>
|
|
52
|
+
{node.content || ''}
|
|
53
|
+
</Text>
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
case 'codeblock':
|
|
57
|
+
return [
|
|
58
|
+
<Box key={`codeblock-${lineCount++}`} borderStyle="single" borderColor="gray" paddingX={1} marginY={1}>
|
|
59
|
+
<Text>{node.content || ''}</Text>
|
|
60
|
+
</Box>
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
case 'list':
|
|
64
|
+
return node.children?.map((item, idx) => (
|
|
65
|
+
<Text key={`list-${lineCount++}-${idx}`}>
|
|
66
|
+
{indent}• {item.content || ''}
|
|
67
|
+
</Text>
|
|
68
|
+
)) || [];
|
|
69
|
+
|
|
70
|
+
case 'blockquote':
|
|
71
|
+
return [
|
|
72
|
+
<Text key={`bq-${lineCount++}`} dimColor>
|
|
73
|
+
{indent}│ {node.content || ''}
|
|
74
|
+
</Text>
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
case 'hr':
|
|
78
|
+
return [
|
|
79
|
+
<Text key={`hr-${lineCount++}`}>
|
|
80
|
+
{'─'.repeat(80)}
|
|
81
|
+
</Text>
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
default:
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderInline(node: MarkdownNode, key: string): JSX.Element {
|
|
90
|
+
switch (node.type) {
|
|
91
|
+
case 'text':
|
|
92
|
+
return <Text key={key}>{node.content || ''}</Text>;
|
|
93
|
+
|
|
94
|
+
case 'bold':
|
|
95
|
+
return <Text key={key} bold>{node.content || ''}</Text>;
|
|
96
|
+
|
|
97
|
+
case 'italic':
|
|
98
|
+
return <Text key={key} dimColor>{node.content || ''}</Text>;
|
|
99
|
+
|
|
100
|
+
case 'code':
|
|
101
|
+
return <Text key={key} inverse>{node.content || ''}</Text>;
|
|
102
|
+
|
|
103
|
+
case 'link':
|
|
104
|
+
return <Text key={key} color="cyan" underline>{node.content || ''}</Text>;
|
|
105
|
+
|
|
106
|
+
default:
|
|
107
|
+
return <Text key={key}>{node.content || ''}</Text>;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Render all nodes
|
|
112
|
+
const allLines: JSX.Element[] = [];
|
|
113
|
+
for (const node of displayNodes) {
|
|
114
|
+
allLines.push(...renderNode(node));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Apply scroll offset
|
|
118
|
+
const visibleLines = scrollOffset > 0 ? allLines.slice(scrollOffset) : allLines;
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<Box flexDirection="column">
|
|
122
|
+
{visibleLines.map((line, idx) => (
|
|
123
|
+
<Box key={idx}>{line}</Box>
|
|
124
|
+
))}
|
|
125
|
+
</Box>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
|
|
4
|
+
interface ProgressBarProps {
|
|
5
|
+
label: string;
|
|
6
|
+
current: number;
|
|
7
|
+
total: number;
|
|
8
|
+
width?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Render a progress bar with label and percentage
|
|
13
|
+
*/
|
|
14
|
+
export function ProgressBar({ label, current, total, width = 20 }: ProgressBarProps) {
|
|
15
|
+
const percent = total > 0 ? Math.round((current / total) * 100) : 0;
|
|
16
|
+
const filled = Math.round((percent / 100) * width);
|
|
17
|
+
const empty = width - filled;
|
|
18
|
+
|
|
19
|
+
const bar = "█".repeat(filled) + "░".repeat(empty);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<Box gap={1}>
|
|
23
|
+
<Text color="yellow">{label}</Text>
|
|
24
|
+
<Text color="cyan">[{bar}]</Text>
|
|
25
|
+
<Text dimColor>
|
|
26
|
+
{current}/{total} ({percent}%)
|
|
27
|
+
</Text>
|
|
28
|
+
</Box>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Simple spinner for indeterminate progress
|
|
34
|
+
*/
|
|
35
|
+
export function Spinner({ label }: { label: string }) {
|
|
36
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
37
|
+
const [frameIndex, setFrameIndex] = React.useState(0);
|
|
38
|
+
|
|
39
|
+
React.useEffect(() => {
|
|
40
|
+
const timer = setInterval(() => {
|
|
41
|
+
setFrameIndex((prev) => (prev + 1) % frames.length);
|
|
42
|
+
}, 80);
|
|
43
|
+
|
|
44
|
+
return () => clearInterval(timer);
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Box gap={1}>
|
|
49
|
+
<Text color="yellow">{frames[frameIndex]}</Text>
|
|
50
|
+
<Text color="yellow">{label}</Text>
|
|
51
|
+
</Box>
|
|
52
|
+
);
|
|
53
|
+
}
|