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,847 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import { existsSync, statSync, readdirSync } from "fs";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import { join, dirname, basename, relative } from "path";
|
|
6
|
+
|
|
7
|
+
export interface DirectoriesStepProps {
|
|
8
|
+
directories: Array<{ path: string; maxDepth: number; label?: string }>;
|
|
9
|
+
onComplete: (directories: Array<{ path: string; maxDepth: number; label?: string }>) => void;
|
|
10
|
+
onBack: () => void;
|
|
11
|
+
onCancel: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface DirectoryItem {
|
|
15
|
+
path: string;
|
|
16
|
+
maxDepth: number;
|
|
17
|
+
label?: string;
|
|
18
|
+
valid: boolean;
|
|
19
|
+
error?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface InputState {
|
|
23
|
+
maxDepth: string;
|
|
24
|
+
label: string;
|
|
25
|
+
showLabelInput: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface SimplifiedBrowserState {
|
|
29
|
+
// Current working directory
|
|
30
|
+
currentPath: string;
|
|
31
|
+
|
|
32
|
+
// Contents of currentPath
|
|
33
|
+
entries: Array<{ name: string; fullPath: string }>;
|
|
34
|
+
|
|
35
|
+
// What user is typing
|
|
36
|
+
inputBuffer: string;
|
|
37
|
+
|
|
38
|
+
// Completions for current input
|
|
39
|
+
completions: string[];
|
|
40
|
+
|
|
41
|
+
// Index for Tab cycling
|
|
42
|
+
completionIndex: number;
|
|
43
|
+
|
|
44
|
+
// Scroll offset for folder list
|
|
45
|
+
scrollOffset: number;
|
|
46
|
+
|
|
47
|
+
// Selected folder index in the filtered list
|
|
48
|
+
selectedFolderIndex: number;
|
|
49
|
+
|
|
50
|
+
error: string | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function validateDirectoryPath(path: string): { valid: boolean; error?: string } {
|
|
54
|
+
if (!path) {
|
|
55
|
+
return { valid: false, error: "Path is required" };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const expanded = path.replace(/^~/, homedir());
|
|
59
|
+
|
|
60
|
+
if (!existsSync(expanded)) {
|
|
61
|
+
return { valid: false, error: "Path does not exist" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const stat = statSync(expanded);
|
|
66
|
+
if (!stat.isDirectory()) {
|
|
67
|
+
return { valid: false, error: "Not a directory" };
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
return { valid: false, error: "Cannot access path" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { valid: true };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Read directory contents, filtering to directories only
|
|
78
|
+
*/
|
|
79
|
+
export function readDirectory(path: string): Array<{ name: string; fullPath: string }> {
|
|
80
|
+
try {
|
|
81
|
+
const entries = readdirSync(path, { withFileTypes: true });
|
|
82
|
+
return entries
|
|
83
|
+
.filter((e) => {
|
|
84
|
+
if (e.name.startsWith(".")) return false;
|
|
85
|
+
|
|
86
|
+
// Regular directory check
|
|
87
|
+
if (e.isDirectory()) return true;
|
|
88
|
+
|
|
89
|
+
// Check if symlink points to a directory
|
|
90
|
+
if (e.isSymbolicLink()) {
|
|
91
|
+
try {
|
|
92
|
+
const fullPath = join(path, e.name);
|
|
93
|
+
return statSync(fullPath).isDirectory();
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return false;
|
|
100
|
+
})
|
|
101
|
+
.map((e) => ({
|
|
102
|
+
name: e.name,
|
|
103
|
+
fullPath: join(path, e.name),
|
|
104
|
+
}))
|
|
105
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
106
|
+
} catch {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get path completions based on input
|
|
113
|
+
*/
|
|
114
|
+
export function getCompletions(input: string, currentPath: string): string[] {
|
|
115
|
+
// Strip trailing slash for proper dirname/basename handling
|
|
116
|
+
const normalizedInput = input.endsWith("/") ? input.slice(0, -1) : input;
|
|
117
|
+
const expanded = normalizedInput.replace(/^~/, homedir());
|
|
118
|
+
|
|
119
|
+
// If input starts with /, it's an absolute path
|
|
120
|
+
if (input.startsWith("/")) {
|
|
121
|
+
const dir = dirname(expanded || "/");
|
|
122
|
+
const prefix = basename(expanded);
|
|
123
|
+
const entries = readDirectory(dir);
|
|
124
|
+
return entries
|
|
125
|
+
.filter((e) => e.name.toLowerCase().startsWith(prefix.toLowerCase()))
|
|
126
|
+
.map((e) => join(dir, e.name));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Relative or home-based path
|
|
130
|
+
const basePath = expanded.startsWith("/") ? "/" : currentPath;
|
|
131
|
+
const dir = join(basePath, dirname(expanded || "."));
|
|
132
|
+
const prefix = basename(expanded);
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const entries = readDirectory(dir);
|
|
136
|
+
return entries
|
|
137
|
+
.filter((e) => e.name.toLowerCase().startsWith(prefix.toLowerCase()))
|
|
138
|
+
.map((e) => join(dir, e.name));
|
|
139
|
+
} catch {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Format path for display (replace home with ~)
|
|
146
|
+
*/
|
|
147
|
+
export function formatDisplayPath(path: string): string {
|
|
148
|
+
const home = homedir();
|
|
149
|
+
if (path.startsWith(home)) {
|
|
150
|
+
return "~" + path.slice(home.length);
|
|
151
|
+
}
|
|
152
|
+
return path;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Hybrid directory browser with typing + navigation
|
|
157
|
+
*/
|
|
158
|
+
interface HybridDirectoryBrowserProps {
|
|
159
|
+
startingPath: string;
|
|
160
|
+
onSelect: (path: string) => void;
|
|
161
|
+
onCancel: () => void;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function HybridDirectoryBrowser({ startingPath, onSelect, onCancel }: HybridDirectoryBrowserProps) {
|
|
165
|
+
const MAX_DISPLAY_FOLDERS = 10;
|
|
166
|
+
|
|
167
|
+
const [browser, setBrowser] = useState<SimplifiedBrowserState>({
|
|
168
|
+
currentPath: startingPath,
|
|
169
|
+
entries: readDirectory(startingPath),
|
|
170
|
+
inputBuffer: "~",
|
|
171
|
+
completions: [],
|
|
172
|
+
completionIndex: 0,
|
|
173
|
+
scrollOffset: 0,
|
|
174
|
+
selectedFolderIndex: 0,
|
|
175
|
+
error: null,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Update completions when input changes
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (browser.inputBuffer.length > 0) {
|
|
181
|
+
const matches = getCompletions(browser.inputBuffer, browser.currentPath);
|
|
182
|
+
setBrowser((prev) => ({
|
|
183
|
+
...prev,
|
|
184
|
+
completions: matches,
|
|
185
|
+
completionIndex: 0,
|
|
186
|
+
}));
|
|
187
|
+
} else {
|
|
188
|
+
setBrowser((prev) => ({
|
|
189
|
+
...prev,
|
|
190
|
+
completions: [],
|
|
191
|
+
completionIndex: 0,
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
}, [browser.inputBuffer, browser.currentPath]);
|
|
195
|
+
|
|
196
|
+
// Update currentPath when input is a valid directory
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (browser.inputBuffer.length > 0) {
|
|
199
|
+
// Expand ~ for validation
|
|
200
|
+
const expanded = browser.inputBuffer.replace(/^~/, homedir());
|
|
201
|
+
// Check if it's a valid directory
|
|
202
|
+
if (existsSync(expanded) && statSync(expanded).isDirectory()) {
|
|
203
|
+
setBrowser((prev) => ({
|
|
204
|
+
...prev,
|
|
205
|
+
currentPath: expanded,
|
|
206
|
+
entries: readDirectory(expanded),
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
// When input is empty, default to home directory
|
|
211
|
+
setBrowser((prev) => ({
|
|
212
|
+
...prev,
|
|
213
|
+
currentPath: startingPath,
|
|
214
|
+
entries: readDirectory(startingPath),
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
}, [browser.inputBuffer, startingPath]);
|
|
218
|
+
|
|
219
|
+
// Reset scroll offset and selection when currentPath changes
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
setBrowser((prev) => ({
|
|
222
|
+
...prev,
|
|
223
|
+
scrollOffset: 0,
|
|
224
|
+
selectedFolderIndex: 0,
|
|
225
|
+
}));
|
|
226
|
+
}, [browser.currentPath]);
|
|
227
|
+
|
|
228
|
+
useInput((input, key) => {
|
|
229
|
+
// Esc: go back to parent directory or cancel
|
|
230
|
+
if (key.escape) {
|
|
231
|
+
// If input ends with / (navigated into a directory), go back to parent
|
|
232
|
+
if (browser.inputBuffer.endsWith("/")) {
|
|
233
|
+
const parentPath = dirname(browser.currentPath);
|
|
234
|
+
setBrowser((prev) => ({
|
|
235
|
+
...prev,
|
|
236
|
+
inputBuffer: "",
|
|
237
|
+
currentPath: parentPath,
|
|
238
|
+
entries: readDirectory(parentPath),
|
|
239
|
+
completions: [],
|
|
240
|
+
scrollOffset: 0,
|
|
241
|
+
selectedFolderIndex: 0,
|
|
242
|
+
error: null,
|
|
243
|
+
}));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// If there's other input beyond ~, clear it (back to ~)
|
|
248
|
+
if (browser.inputBuffer.length > 1) {
|
|
249
|
+
setBrowser((prev) => ({
|
|
250
|
+
...prev,
|
|
251
|
+
inputBuffer: "~",
|
|
252
|
+
completions: [],
|
|
253
|
+
selectedFolderIndex: 0,
|
|
254
|
+
scrollOffset: 0,
|
|
255
|
+
error: null,
|
|
256
|
+
}));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Otherwise cancel
|
|
261
|
+
onCancel();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Tab: cycle completions and update current path
|
|
266
|
+
if (key.tab && browser.completions.length > 0) {
|
|
267
|
+
const nextIndex = (browser.completionIndex + 1) % browser.completions.length;
|
|
268
|
+
const completedPath = browser.completions[nextIndex]!;
|
|
269
|
+
|
|
270
|
+
// Check if it's a directory and add trailing slash
|
|
271
|
+
const expanded = completedPath.replace(/^~/, homedir());
|
|
272
|
+
const isDirectory = existsSync(expanded) && statSync(expanded).isDirectory();
|
|
273
|
+
|
|
274
|
+
// Use ~ format for home directory paths in input (for backspace-ability)
|
|
275
|
+
const home = homedir();
|
|
276
|
+
const inputPath = completedPath.startsWith(home)
|
|
277
|
+
? "~" + completedPath.slice(home.length)
|
|
278
|
+
: completedPath;
|
|
279
|
+
const inputWithSlash = isDirectory ? inputPath + "/" : inputPath;
|
|
280
|
+
|
|
281
|
+
setBrowser((prev) => ({
|
|
282
|
+
...prev,
|
|
283
|
+
completionIndex: nextIndex,
|
|
284
|
+
inputBuffer: inputWithSlash,
|
|
285
|
+
currentPath: completedPath,
|
|
286
|
+
entries: readDirectory(completedPath),
|
|
287
|
+
scrollOffset: 0,
|
|
288
|
+
selectedFolderIndex: 0,
|
|
289
|
+
error: null,
|
|
290
|
+
}));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Compute filtered entries for navigation (only filter if not a path)
|
|
295
|
+
const isPathInput = browser.inputBuffer.includes("/");
|
|
296
|
+
const filteredEntries = (browser.inputBuffer.length > 0 && !isPathInput)
|
|
297
|
+
? browser.entries.filter(e =>
|
|
298
|
+
e.name.toLowerCase().includes(browser.inputBuffer.toLowerCase())
|
|
299
|
+
)
|
|
300
|
+
: browser.entries;
|
|
301
|
+
|
|
302
|
+
// j/k or arrow keys for navigating filtered folder list
|
|
303
|
+
if (input === "j" || key.downArrow) {
|
|
304
|
+
const maxIndex = Math.max(filteredEntries.length - 1, 0);
|
|
305
|
+
setBrowser((prev) => ({
|
|
306
|
+
...prev,
|
|
307
|
+
selectedFolderIndex: Math.min(prev.selectedFolderIndex + 1, maxIndex),
|
|
308
|
+
// Auto-scroll if selection goes below visible area
|
|
309
|
+
scrollOffset: Math.max(prev.scrollOffset,
|
|
310
|
+
Math.min(prev.selectedFolderIndex + 1 - MAX_DISPLAY_FOLDERS + 1,
|
|
311
|
+
Math.max(filteredEntries.length - MAX_DISPLAY_FOLDERS, 0))),
|
|
312
|
+
}));
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (input === "k" || key.upArrow) {
|
|
317
|
+
setBrowser((prev) => ({
|
|
318
|
+
...prev,
|
|
319
|
+
selectedFolderIndex: Math.max(prev.selectedFolderIndex - 1, 0),
|
|
320
|
+
// Auto-scroll if selection goes above visible area
|
|
321
|
+
scrollOffset: Math.min(prev.scrollOffset, prev.selectedFolderIndex - 1),
|
|
322
|
+
}));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Space or Enter: select folder from filtered list or navigate current path
|
|
327
|
+
if (input === " " || key.return) {
|
|
328
|
+
// If there's a selected folder in filtered list, navigate into it
|
|
329
|
+
if (filteredEntries.length > 0 && browser.selectedFolderIndex < filteredEntries.length) {
|
|
330
|
+
const selectedEntry = filteredEntries[browser.selectedFolderIndex]!;
|
|
331
|
+
// Use ~ format for home directory paths in input
|
|
332
|
+
const home = homedir();
|
|
333
|
+
const inputPath = selectedEntry.fullPath.startsWith(home)
|
|
334
|
+
? "~" + selectedEntry.fullPath.slice(home.length)
|
|
335
|
+
: selectedEntry.fullPath;
|
|
336
|
+
setBrowser((prev) => ({
|
|
337
|
+
...prev,
|
|
338
|
+
inputBuffer: inputPath + "/",
|
|
339
|
+
currentPath: selectedEntry.fullPath,
|
|
340
|
+
entries: readDirectory(selectedEntry.fullPath),
|
|
341
|
+
completions: [],
|
|
342
|
+
scrollOffset: 0,
|
|
343
|
+
selectedFolderIndex: 0,
|
|
344
|
+
error: null,
|
|
345
|
+
}));
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Otherwise, accept current path
|
|
350
|
+
if (key.return) {
|
|
351
|
+
const targetPath = browser.inputBuffer.replace(/\/$/, "") || browser.currentPath;
|
|
352
|
+
const validation = validateDirectoryPath(targetPath);
|
|
353
|
+
if (validation.valid) {
|
|
354
|
+
onSelect(targetPath);
|
|
355
|
+
} else {
|
|
356
|
+
setBrowser((prev) => ({
|
|
357
|
+
...prev,
|
|
358
|
+
error: validation.error || "Invalid path",
|
|
359
|
+
}));
|
|
360
|
+
}
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Space on current path with no selection
|
|
365
|
+
onSelect(browser.currentPath);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Ctrl+U: clear input back to ~
|
|
370
|
+
if (key.ctrl && input === 'u') {
|
|
371
|
+
setBrowser((prev) => ({
|
|
372
|
+
...prev,
|
|
373
|
+
inputBuffer: "~",
|
|
374
|
+
completions: [],
|
|
375
|
+
selectedFolderIndex: 0,
|
|
376
|
+
scrollOffset: 0,
|
|
377
|
+
error: null,
|
|
378
|
+
}));
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Backspace/delete
|
|
383
|
+
if ((key as any).backspace || (key as any).delete) {
|
|
384
|
+
if (browser.inputBuffer.length > 0) {
|
|
385
|
+
setBrowser((prev) => ({
|
|
386
|
+
...prev,
|
|
387
|
+
inputBuffer: prev.inputBuffer.slice(0, -1),
|
|
388
|
+
selectedFolderIndex: 0,
|
|
389
|
+
scrollOffset: 0,
|
|
390
|
+
error: null,
|
|
391
|
+
}));
|
|
392
|
+
}
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Regular character input
|
|
397
|
+
if (input.length === 1 && /[a-zA-Z0-9_\/\.~-]/.test(input)) {
|
|
398
|
+
setBrowser((prev) => ({
|
|
399
|
+
...prev,
|
|
400
|
+
inputBuffer: prev.inputBuffer + input,
|
|
401
|
+
selectedFolderIndex: 0,
|
|
402
|
+
scrollOffset: 0,
|
|
403
|
+
error: null,
|
|
404
|
+
}));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
return (
|
|
410
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
411
|
+
{/* Text input */}
|
|
412
|
+
<Box marginBottom={1}>
|
|
413
|
+
<Box gap={1}>
|
|
414
|
+
<Text color="yellow">{"> "}</Text>
|
|
415
|
+
<Text color="yellow">
|
|
416
|
+
{browser.inputBuffer}
|
|
417
|
+
</Text>
|
|
418
|
+
<Text color="yellow" inverse>_</Text>
|
|
419
|
+
</Box>
|
|
420
|
+
</Box>
|
|
421
|
+
|
|
422
|
+
{/* Error message */}
|
|
423
|
+
{browser.error && (
|
|
424
|
+
<Box marginBottom={1}>
|
|
425
|
+
<Text color="red">{browser.error}</Text>
|
|
426
|
+
</Box>
|
|
427
|
+
)}
|
|
428
|
+
|
|
429
|
+
{/* Directory list - filtered and scrollable */}
|
|
430
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
431
|
+
<Text dimColor>Folders in {formatDisplayPath(browser.currentPath)}:</Text>
|
|
432
|
+
{(() => {
|
|
433
|
+
// Only filter by name if input doesn't contain slashes (not a path)
|
|
434
|
+
const isPathInput = browser.inputBuffer.includes("/");
|
|
435
|
+
const filteredEntries = (browser.inputBuffer.length > 0 && !isPathInput)
|
|
436
|
+
? browser.entries.filter(e =>
|
|
437
|
+
e.name.toLowerCase().includes(browser.inputBuffer.toLowerCase())
|
|
438
|
+
)
|
|
439
|
+
: browser.entries;
|
|
440
|
+
|
|
441
|
+
if (filteredEntries.length === 0) {
|
|
442
|
+
return <Text dimColor>No matching folders</Text>;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const visibleEntries = filteredEntries.slice(
|
|
446
|
+
browser.scrollOffset,
|
|
447
|
+
browser.scrollOffset + MAX_DISPLAY_FOLDERS
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
return (
|
|
451
|
+
<>
|
|
452
|
+
{browser.scrollOffset > 0 && (
|
|
453
|
+
<Text dimColor>▲ {browser.scrollOffset} more above</Text>
|
|
454
|
+
)}
|
|
455
|
+
{visibleEntries.map((item, idx) => {
|
|
456
|
+
const globalIndex = browser.scrollOffset + idx;
|
|
457
|
+
const isSelected = globalIndex === browser.selectedFolderIndex;
|
|
458
|
+
return (
|
|
459
|
+
<Text key={item.fullPath}>
|
|
460
|
+
{isSelected ? <Text color="cyan">●</Text> : <Text dimColor>○</Text>}
|
|
461
|
+
{" "}{item.name}/
|
|
462
|
+
</Text>
|
|
463
|
+
);
|
|
464
|
+
})}
|
|
465
|
+
{browser.scrollOffset + MAX_DISPLAY_FOLDERS < filteredEntries.length && (
|
|
466
|
+
<Text dimColor>▼ {filteredEntries.length - browser.scrollOffset - MAX_DISPLAY_FOLDERS} more below</Text>
|
|
467
|
+
)}
|
|
468
|
+
</>
|
|
469
|
+
);
|
|
470
|
+
})()}
|
|
471
|
+
</Box>
|
|
472
|
+
|
|
473
|
+
{/* Help text */}
|
|
474
|
+
<Box flexDirection="column" marginTop={1}>
|
|
475
|
+
<Box gap={2}>
|
|
476
|
+
<Text dimColor>Tab:</Text>
|
|
477
|
+
<Text dimColor>complete</Text>
|
|
478
|
+
<Text dimColor>j/k:</Text>
|
|
479
|
+
<Text dimColor>navigate</Text>
|
|
480
|
+
<Text dimColor>Space:</Text>
|
|
481
|
+
<Text dimColor>navigate folder</Text>
|
|
482
|
+
</Box>
|
|
483
|
+
<Box gap={2}>
|
|
484
|
+
<Text dimColor>Enter:</Text>
|
|
485
|
+
<Text dimColor>select</Text>
|
|
486
|
+
<Text dimColor>Esc:</Text>
|
|
487
|
+
<Text dimColor>go back</Text>
|
|
488
|
+
</Box>
|
|
489
|
+
</Box>
|
|
490
|
+
</Box>
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export function DirectoriesStep({
|
|
495
|
+
directories,
|
|
496
|
+
onComplete,
|
|
497
|
+
onBack,
|
|
498
|
+
onCancel,
|
|
499
|
+
}: DirectoriesStepProps) {
|
|
500
|
+
const [items, setItems] = useState<DirectoryItem[]>(
|
|
501
|
+
directories.map((d) => ({
|
|
502
|
+
...d,
|
|
503
|
+
valid: true,
|
|
504
|
+
}))
|
|
505
|
+
);
|
|
506
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
507
|
+
const [mode, setMode] = useState<"list" | "browse" | "maxDepth" | "label">("list");
|
|
508
|
+
const [input, setInput] = useState<InputState>({
|
|
509
|
+
maxDepth: "2",
|
|
510
|
+
label: "",
|
|
511
|
+
showLabelInput: false,
|
|
512
|
+
});
|
|
513
|
+
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
|
514
|
+
const [error, setError] = useState<string | null>(null);
|
|
515
|
+
|
|
516
|
+
useInput((inputStr, key) => {
|
|
517
|
+
if (mode === "browse") {
|
|
518
|
+
// Handled by HybridDirectoryBrowser
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (mode === "maxDepth" || mode === "label") {
|
|
523
|
+
handleDepthOrLabelInput(inputStr, key);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// List mode
|
|
528
|
+
if (key.escape || inputStr === "q" || inputStr === "Q") {
|
|
529
|
+
onCancel();
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if ((key as any).backspace || (key as any).delete) {
|
|
534
|
+
onBack();
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (key.return) {
|
|
539
|
+
if (items.length > 0) {
|
|
540
|
+
handleComplete();
|
|
541
|
+
}
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Navigate items
|
|
546
|
+
if (inputStr === "j" || key.downArrow) {
|
|
547
|
+
setSelectedIndex((i) => Math.min(i + 1, Math.max(items.length - 1, 0)));
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (inputStr === "k" || key.upArrow) {
|
|
552
|
+
setSelectedIndex((i) => Math.max(i - 1, 0));
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Add directory
|
|
557
|
+
if (inputStr === "a" || inputStr === "A") {
|
|
558
|
+
setMode("browse");
|
|
559
|
+
setError(null);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Delete selected
|
|
564
|
+
if ((inputStr === "d" || inputStr === "D") && items.length > 0) {
|
|
565
|
+
setItems((prev) => prev.filter((_, i) => i !== selectedIndex));
|
|
566
|
+
if (selectedIndex >= items.length - 1) {
|
|
567
|
+
setSelectedIndex(Math.max(items.length - 2, 0));
|
|
568
|
+
}
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Skip with defaults
|
|
573
|
+
if (inputStr === "s" || inputStr === "S") {
|
|
574
|
+
handleSkip();
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Edit selected
|
|
579
|
+
if ((inputStr === "e" || inputStr === "E") && items.length > 0) {
|
|
580
|
+
const item = items[selectedIndex];
|
|
581
|
+
if (item) {
|
|
582
|
+
setSelectedPath(item.path);
|
|
583
|
+
setMode("maxDepth");
|
|
584
|
+
setInput({
|
|
585
|
+
maxDepth: String(item.maxDepth),
|
|
586
|
+
label: item.label || "",
|
|
587
|
+
showLabelInput: !!item.label,
|
|
588
|
+
});
|
|
589
|
+
setError(null);
|
|
590
|
+
}
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
const handleDepthOrLabelInput = (inputStr: string, key: { return: boolean; escape: boolean; tab: boolean }) => {
|
|
596
|
+
if (key.escape) {
|
|
597
|
+
// In maxDepth mode, go back to browser
|
|
598
|
+
if (mode === "maxDepth") {
|
|
599
|
+
setMode("browse");
|
|
600
|
+
setError(null);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
// In label mode, go back to list
|
|
604
|
+
setMode("list");
|
|
605
|
+
setSelectedPath(null);
|
|
606
|
+
setInput({ maxDepth: "2", label: "", showLabelInput: false });
|
|
607
|
+
setError(null);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (key.return) {
|
|
612
|
+
if (mode === "maxDepth") {
|
|
613
|
+
const depth = parseInt(input.maxDepth, 10);
|
|
614
|
+
if (isNaN(depth) || depth < 0 || depth > 10) {
|
|
615
|
+
setError("Max depth must be between 0 and 10");
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
setMode("label");
|
|
619
|
+
setError(null);
|
|
620
|
+
} else if (mode === "label") {
|
|
621
|
+
// Add or update the directory
|
|
622
|
+
if (!selectedPath) return;
|
|
623
|
+
|
|
624
|
+
const newItem: DirectoryItem = {
|
|
625
|
+
path: selectedPath,
|
|
626
|
+
maxDepth: parseInt(input.maxDepth, 10),
|
|
627
|
+
label: input.label || undefined,
|
|
628
|
+
valid: true,
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
setItems((prev) => {
|
|
632
|
+
const existingIndex = prev.findIndex((i) => i.path === selectedPath);
|
|
633
|
+
if (existingIndex >= 0) {
|
|
634
|
+
const updated = [...prev];
|
|
635
|
+
updated[existingIndex] = newItem;
|
|
636
|
+
return updated;
|
|
637
|
+
}
|
|
638
|
+
return [...prev, newItem];
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
setMode("list");
|
|
642
|
+
setSelectedPath(null);
|
|
643
|
+
setInput({ maxDepth: "2", label: "", showLabelInput: false });
|
|
644
|
+
setError(null);
|
|
645
|
+
}
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (key.tab && mode === "maxDepth") {
|
|
650
|
+
setInput((prev) => ({ ...prev, showLabelInput: !prev.showLabelInput }));
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (mode === "maxDepth") {
|
|
655
|
+
if ((key as any).backspace || (key as any).delete) {
|
|
656
|
+
setInput((prev) => ({ ...prev, maxDepth: prev.maxDepth.slice(0, -1) }));
|
|
657
|
+
} else if (/^[0-9]$/.test(inputStr)) {
|
|
658
|
+
setInput((prev) => ({ ...prev, maxDepth: prev.maxDepth + inputStr }));
|
|
659
|
+
}
|
|
660
|
+
} else if (mode === "label") {
|
|
661
|
+
if ((key as any).backspace || (key as any).delete) {
|
|
662
|
+
setInput((prev) => ({ ...prev, label: prev.label.slice(0, -1) }));
|
|
663
|
+
} else {
|
|
664
|
+
setInput((prev) => ({ ...prev, label: prev.label + inputStr }));
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
const handleBrowserSelect = (path: string) => {
|
|
670
|
+
setSelectedPath(path);
|
|
671
|
+
setMode("maxDepth");
|
|
672
|
+
setInput({ maxDepth: "2", label: "", showLabelInput: false });
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
const handleBrowserCancel = () => {
|
|
676
|
+
setMode("list");
|
|
677
|
+
setError(null);
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
const handleSkip = () => {
|
|
681
|
+
const defaultDir: DirectoryItem = {
|
|
682
|
+
path: `${homedir()}/projects`,
|
|
683
|
+
maxDepth: 2,
|
|
684
|
+
label: "Projects",
|
|
685
|
+
valid: true,
|
|
686
|
+
};
|
|
687
|
+
onComplete([{ path: defaultDir.path, maxDepth: defaultDir.maxDepth, label: defaultDir.label }]);
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
const handleComplete = () => {
|
|
691
|
+
const validItems = items.filter((i) => i.valid);
|
|
692
|
+
if (validItems.length === 0) {
|
|
693
|
+
setError("Add at least one directory");
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
onComplete(
|
|
697
|
+
validItems.map((i) => ({
|
|
698
|
+
path: i.path,
|
|
699
|
+
maxDepth: i.maxDepth,
|
|
700
|
+
label: i.label,
|
|
701
|
+
}))
|
|
702
|
+
);
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
return (
|
|
706
|
+
<Box
|
|
707
|
+
flexDirection="column"
|
|
708
|
+
borderStyle="round"
|
|
709
|
+
borderColor="cyan"
|
|
710
|
+
paddingX={2}
|
|
711
|
+
paddingY={1}
|
|
712
|
+
width={80}
|
|
713
|
+
>
|
|
714
|
+
{/* Title */}
|
|
715
|
+
<Box marginBottom={1}>
|
|
716
|
+
<Text bold color="cyan">
|
|
717
|
+
Configure Project Directories
|
|
718
|
+
</Text>
|
|
719
|
+
</Box>
|
|
720
|
+
|
|
721
|
+
{/* Instructions */}
|
|
722
|
+
{mode === "list" && (
|
|
723
|
+
<Box marginBottom={1}>
|
|
724
|
+
<Text>Add the directories containing your git projects.</Text>
|
|
725
|
+
</Box>
|
|
726
|
+
)}
|
|
727
|
+
|
|
728
|
+
{/* Browser mode */}
|
|
729
|
+
{mode === "browse" && (
|
|
730
|
+
<HybridDirectoryBrowser
|
|
731
|
+
startingPath={homedir()}
|
|
732
|
+
onSelect={handleBrowserSelect}
|
|
733
|
+
onCancel={handleBrowserCancel}
|
|
734
|
+
/>
|
|
735
|
+
)}
|
|
736
|
+
|
|
737
|
+
{/* MaxDepth input */}
|
|
738
|
+
{mode === "maxDepth" && (
|
|
739
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
740
|
+
<Text>Max scan depth (0-10, default 2):</Text>
|
|
741
|
+
<Box gap={1}>
|
|
742
|
+
<Text color="yellow">{"> "}</Text>
|
|
743
|
+
<Text color="yellow">{input.maxDepth}</Text>
|
|
744
|
+
<Text color="yellow" inverse>_</Text>
|
|
745
|
+
</Box>
|
|
746
|
+
<Text dimColor>Path: {formatDisplayPath(selectedPath || "")}</Text>
|
|
747
|
+
<Text dimColor>Press Enter to confirm, Esc to cancel</Text>
|
|
748
|
+
</Box>
|
|
749
|
+
)}
|
|
750
|
+
|
|
751
|
+
{/* Label input */}
|
|
752
|
+
{mode === "label" && (
|
|
753
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
754
|
+
<Text>Label (optional, press Enter to skip):</Text>
|
|
755
|
+
<Box gap={1}>
|
|
756
|
+
<Text color="yellow">{"> "}</Text>
|
|
757
|
+
<Text color="yellow">{input.label}</Text>
|
|
758
|
+
<Text color="yellow" inverse>_</Text>
|
|
759
|
+
</Box>
|
|
760
|
+
<Text dimColor>Path: {formatDisplayPath(selectedPath || "")}</Text>
|
|
761
|
+
<Text dimColor>Press Enter to confirm, Esc to cancel</Text>
|
|
762
|
+
</Box>
|
|
763
|
+
)}
|
|
764
|
+
|
|
765
|
+
{/* List mode */}
|
|
766
|
+
{mode === "list" && (
|
|
767
|
+
<>
|
|
768
|
+
{/* Current directories */}
|
|
769
|
+
{items.length > 0 && (
|
|
770
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
771
|
+
<Text dimColor>Current directories:</Text>
|
|
772
|
+
{items.map((item, index) => (
|
|
773
|
+
<Box key={item.path} gap={2}>
|
|
774
|
+
<Text color={index === selectedIndex ? "cyan" : "gray"}>
|
|
775
|
+
{index === selectedIndex ? "●" : "○"}
|
|
776
|
+
</Text>
|
|
777
|
+
<Text color={index === selectedIndex ? "white" : "gray"}>
|
|
778
|
+
{item.label || item.path}
|
|
779
|
+
</Text>
|
|
780
|
+
<Text dimColor>
|
|
781
|
+
(depth: {item.maxDepth})
|
|
782
|
+
</Text>
|
|
783
|
+
</Box>
|
|
784
|
+
))}
|
|
785
|
+
</Box>
|
|
786
|
+
)}
|
|
787
|
+
|
|
788
|
+
{/* Empty state */}
|
|
789
|
+
{items.length === 0 && (
|
|
790
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
791
|
+
<Text dimColor>No directories added yet.</Text>
|
|
792
|
+
<Text dimColor>Press 'a' to browse your filesystem.</Text>
|
|
793
|
+
</Box>
|
|
794
|
+
)}
|
|
795
|
+
|
|
796
|
+
{/* Error message */}
|
|
797
|
+
{error && (
|
|
798
|
+
<Box marginBottom={1}>
|
|
799
|
+
<Text color="red">{error}</Text>
|
|
800
|
+
</Box>
|
|
801
|
+
)}
|
|
802
|
+
|
|
803
|
+
{/* Actions */}
|
|
804
|
+
<Box gap={2} marginTop={1}>
|
|
805
|
+
<Text dimColor>Shortcuts:</Text>
|
|
806
|
+
<Text color="green" bold>
|
|
807
|
+
a
|
|
808
|
+
</Text>
|
|
809
|
+
<Text dimColor>Add</Text>
|
|
810
|
+
{items.length > 0 && (
|
|
811
|
+
<>
|
|
812
|
+
<Text color="green" bold>
|
|
813
|
+
d
|
|
814
|
+
</Text>
|
|
815
|
+
<Text dimColor>Delete</Text>
|
|
816
|
+
<Text color="green" bold>
|
|
817
|
+
e
|
|
818
|
+
</Text>
|
|
819
|
+
<Text dimColor>Edit</Text>
|
|
820
|
+
</>
|
|
821
|
+
)}
|
|
822
|
+
<Text color="green" bold>
|
|
823
|
+
s
|
|
824
|
+
</Text>
|
|
825
|
+
<Text dimColor>Skip (defaults)</Text>
|
|
826
|
+
</Box>
|
|
827
|
+
<Box gap={2}>
|
|
828
|
+
<Text dimColor>j/k</Text>
|
|
829
|
+
<Text dimColor>Navigate</Text>
|
|
830
|
+
<Text color="green" bold>
|
|
831
|
+
Enter
|
|
832
|
+
</Text>
|
|
833
|
+
<Text dimColor>Continue</Text>
|
|
834
|
+
<Text color="red" bold>
|
|
835
|
+
Backspace
|
|
836
|
+
</Text>
|
|
837
|
+
<Text dimColor>Back</Text>
|
|
838
|
+
<Text color="red" bold>
|
|
839
|
+
q
|
|
840
|
+
</Text>
|
|
841
|
+
<Text dimColor>Quit</Text>
|
|
842
|
+
</Box>
|
|
843
|
+
</>
|
|
844
|
+
)}
|
|
845
|
+
</Box>
|
|
846
|
+
);
|
|
847
|
+
}
|