gitforest 1.1.2 → 1.2.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.
@@ -0,0 +1,151 @@
1
+ import { Box, Text, useInput } from "ink";
2
+ import { homedir } from "os";
3
+ import {
4
+ HybridDirectoryBrowser,
5
+ formatDisplayPath,
6
+ } from "./onboarding/DirectoriesStep.tsx";
7
+ import type { AddDirectoryDialogState } from "../types/index.ts";
8
+
9
+ export interface AddDirectoryDialogProps {
10
+ state: AddDirectoryDialogState;
11
+ onUpdate: (updates: Partial<AddDirectoryDialogState>) => void;
12
+ onConfirm: (path: string, maxDepth: number, label: string) => void;
13
+ onCancel: () => void;
14
+ }
15
+
16
+ export function AddDirectoryDialog({
17
+ state,
18
+ onUpdate,
19
+ onConfirm,
20
+ onCancel,
21
+ }: AddDirectoryDialogProps) {
22
+ const { step, selectedPath, maxDepthInput, labelInput, error } = state;
23
+
24
+ // Handle maxDepth and label input
25
+ useInput((input, key) => {
26
+ // Browse mode is handled entirely by HybridDirectoryBrowser
27
+ if (step === "browse") return;
28
+
29
+ if (key.escape) {
30
+ if (step === "maxDepth") {
31
+ // Go back to browser
32
+ onUpdate({ step: "browse", selectedPath: null, error: null });
33
+ } else if (step === "label") {
34
+ // Go back to maxDepth
35
+ onUpdate({ step: "maxDepth", error: null });
36
+ }
37
+ return;
38
+ }
39
+
40
+ if (key.return) {
41
+ if (step === "maxDepth") {
42
+ const depth = parseInt(maxDepthInput, 10);
43
+ if (isNaN(depth) || depth < 0 || depth > 10) {
44
+ onUpdate({ error: "Max depth must be between 0 and 10" });
45
+ return;
46
+ }
47
+ onUpdate({ step: "label", error: null });
48
+ } else if (step === "label") {
49
+ if (!selectedPath) return;
50
+ const depth = parseInt(maxDepthInput, 10);
51
+ onConfirm(selectedPath, isNaN(depth) ? 2 : depth, labelInput.trim());
52
+ }
53
+ return;
54
+ }
55
+
56
+ if (step === "maxDepth") {
57
+ if ((key as any).backspace || (key as any).delete) {
58
+ onUpdate({ maxDepthInput: maxDepthInput.slice(0, -1) });
59
+ } else if (/^[0-9]$/.test(input)) {
60
+ onUpdate({ maxDepthInput: maxDepthInput + input });
61
+ }
62
+ } else if (step === "label") {
63
+ if ((key as any).backspace || (key as any).delete) {
64
+ onUpdate({ labelInput: labelInput.slice(0, -1) });
65
+ } else if (input.length === 1) {
66
+ onUpdate({ labelInput: labelInput + input });
67
+ }
68
+ }
69
+ });
70
+
71
+ const handleBrowserSelect = (path: string) => {
72
+ onUpdate({
73
+ selectedPath: path,
74
+ step: "maxDepth",
75
+ maxDepthInput: "2",
76
+ labelInput: "",
77
+ error: null,
78
+ });
79
+ };
80
+
81
+ const handleBrowserCancel = () => {
82
+ onCancel();
83
+ };
84
+
85
+ return (
86
+ <Box
87
+ flexDirection="column"
88
+ borderStyle="round"
89
+ borderColor="cyan"
90
+ paddingX={2}
91
+ paddingY={1}
92
+ width={80}
93
+ >
94
+ <Box marginBottom={1}>
95
+ <Text bold color="cyan">
96
+ Add Directory
97
+ </Text>
98
+ </Box>
99
+
100
+ {/* Step 1: Browse for directory */}
101
+ {step === "browse" && (
102
+ <HybridDirectoryBrowser
103
+ startingPath={homedir()}
104
+ onSelect={handleBrowserSelect}
105
+ onCancel={handleBrowserCancel}
106
+ />
107
+ )}
108
+
109
+ {/* Step 2: Max depth input */}
110
+ {step === "maxDepth" && (
111
+ <Box flexDirection="column" marginBottom={1}>
112
+ <Text>Max scan depth (0-10, default 2):</Text>
113
+ <Box gap={1}>
114
+ <Text color="yellow">{"> "}</Text>
115
+ <Text color="yellow">{maxDepthInput}</Text>
116
+ <Text color="yellow" inverse>_</Text>
117
+ </Box>
118
+ <Text dimColor>Path: {formatDisplayPath(selectedPath || "")}</Text>
119
+ {error && <Text color="red">{error}</Text>}
120
+ <Box marginTop={1} gap={2}>
121
+ <Text dimColor>Enter:</Text>
122
+ <Text dimColor>continue</Text>
123
+ <Text dimColor>Esc:</Text>
124
+ <Text dimColor>back to browser</Text>
125
+ </Box>
126
+ </Box>
127
+ )}
128
+
129
+ {/* Step 3: Label input */}
130
+ {step === "label" && (
131
+ <Box flexDirection="column" marginBottom={1}>
132
+ <Text>Label (optional, press Enter to skip):</Text>
133
+ <Box gap={1}>
134
+ <Text color="yellow">{"> "}</Text>
135
+ <Text color="yellow">{labelInput}</Text>
136
+ <Text color="yellow" inverse>_</Text>
137
+ </Box>
138
+ <Text dimColor>Path: {formatDisplayPath(selectedPath || "")}</Text>
139
+ <Text dimColor>Depth: {maxDepthInput}</Text>
140
+ {error && <Text color="red">{error}</Text>}
141
+ <Box marginTop={1} gap={2}>
142
+ <Text dimColor>Enter:</Text>
143
+ <Text dimColor>confirm</Text>
144
+ <Text dimColor>Esc:</Text>
145
+ <Text dimColor>back to depth</Text>
146
+ </Box>
147
+ </Box>
148
+ )}
149
+ </Box>
150
+ );
151
+ }
@@ -58,6 +58,7 @@ const HELP_SECTIONS = [
58
58
  { key: "/", desc: "Filter projects" },
59
59
  { key: "s", desc: "Cycle sort field" },
60
60
  { key: "x", desc: "Command palette" },
61
+ { key: "+", desc: "Add directory" },
61
62
  { key: "r", desc: "Refresh" },
62
63
  { key: "?", desc: "Toggle help" },
63
64
  { key: "Esc", desc: "Cancel / exit mode" },
@@ -7,21 +7,24 @@ import { ConfirmDialog } from "./ConfirmDialog.tsx";
7
7
  import { CloneDialog } from "./CloneDialog.tsx";
8
8
  import { RepoDetailModal } from "./RepoDetailModal.tsx";
9
9
  import { CommandPalette } from "./CommandPalette.tsx";
10
+ import { AddDirectoryDialog } from "./AddDirectoryDialog.tsx";
10
11
  import { useStore, useFilteredUnifiedRepos, useSelectedUnifiedRepos } from "../state/store.tsx";
11
12
  import { useConfirmDialogActions } from "../hooks/useConfirmDialogActions.ts";
12
- import { startAction, endAction, setMessage } from "../state/actions.ts";
13
+ import { startAction, endAction, setMessage, updateAddDirectoryDialog, hideAddDirectoryDialog } from "../state/actions.ts";
13
14
  import { executeCommand } from "../operations/commands.ts";
14
15
  import { errorToString } from "../utils/errors.ts";
16
+ import { openInEditor, openInBrowser } from "../services/editor.ts";
15
17
  import { UI } from "../constants.ts";
16
- import type { GitforestConfig, CommandConfig } from "../types/index.ts";
18
+ import type { GitforestConfig, CommandConfig, UnifiedRepo } from "../types/index.ts";
17
19
 
18
20
  interface LayoutProps {
19
21
  config: GitforestConfig;
20
- onRefresh: () => Promise<void>;
21
- onClone?: (repos: any[], targetDir: string, useSSH: boolean) => Promise<void>;
22
+ onRefresh: (options?: { forceRefresh?: boolean }) => Promise<void>;
23
+ onClone?: (repos: UnifiedRepo[], targetDir: string, useSSH: boolean) => Promise<void>;
24
+ onAddDirectory?: (path: string, maxDepth: number, label: string) => Promise<{ success: boolean; error?: string }>;
22
25
  }
23
26
 
24
- export function Layout({ config, onRefresh, onClone }: LayoutProps) {
27
+ export function Layout({ config, onRefresh, onClone, onAddDirectory }: LayoutProps) {
25
28
  const { state, dispatch } = useStore();
26
29
  const { mode, confirmDialog, cloneDialog } = state;
27
30
  const filteredRepos = useFilteredUnifiedRepos();
@@ -178,13 +181,9 @@ export function Layout({ config, onRefresh, onClone }: LayoutProps) {
178
181
  break;
179
182
  case "browser":
180
183
  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)}`));
184
+ const browserResult = await openInBrowser(repo.github.htmlUrl);
185
+ if (!browserResult.success) {
186
+ dispatch(setMessage(`Failed to open browser: ${browserResult.error}`));
188
187
  }
189
188
  } else {
190
189
  dispatch(setMessage("No GitHub URL available for this repository"));
@@ -192,90 +191,9 @@ export function Layout({ config, onRefresh, onClone }: LayoutProps) {
192
191
  break;
193
192
  case "editor":
194
193
  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);
194
+ const editorResult = await openInEditor(repo.localPath, config);
195
+ if (!editorResult.success) {
196
+ dispatch(setMessage(`Failed to open editor: ${editorResult.error}`));
279
197
  }
280
198
  } else {
281
199
  dispatch(setMessage("No local path available for this repository"));
@@ -303,8 +221,8 @@ export function Layout({ config, onRefresh, onClone }: LayoutProps) {
303
221
  const result = await executeCommand(command, projectPath);
304
222
  dispatch(endAction());
305
223
  if (result.success) {
306
- const shortOutput = result.output && result.output.length > 50
307
- ? result.output.slice(0, 50) + "..."
224
+ const shortOutput = result.output && result.output.length > UI.OUTPUT_TRUNCATION_LENGTH
225
+ ? result.output.slice(0, UI.OUTPUT_TRUNCATION_LENGTH) + "..."
308
226
  : result.output || "Done";
309
227
  dispatch(setMessage(`${command.name}: ${shortOutput}`));
310
228
  } else {
@@ -312,7 +230,7 @@ export function Layout({ config, onRefresh, onClone }: LayoutProps) {
312
230
  }
313
231
  } catch (error) {
314
232
  dispatch(endAction());
315
- dispatch(setMessage(`${command.name} failed: ${error instanceof Error ? error.message : String(error)}`));
233
+ dispatch(setMessage(`${command.name} failed: ${errorToString(error)}`));
316
234
  }
317
235
  };
318
236
 
@@ -334,6 +252,33 @@ export function Layout({ config, onRefresh, onClone }: LayoutProps) {
334
252
  );
335
253
  }
336
254
 
255
+ // Show add directory dialog
256
+ if (mode === "add-directory" && state.addDirectoryDialog) {
257
+ const handleAddDirConfirm = async (path: string, maxDepth: number, label: string) => {
258
+ if (onAddDirectory) {
259
+ const result = await onAddDirectory(path, maxDepth, label);
260
+ if (result.success) {
261
+ dispatch(hideAddDirectoryDialog());
262
+ dispatch(setMessage(`Added directory: ${path}`));
263
+ await onRefresh({ forceRefresh: true });
264
+ } else {
265
+ dispatch(updateAddDirectoryDialog({ error: result.error ?? "Failed to add directory" }));
266
+ }
267
+ }
268
+ };
269
+
270
+ return (
271
+ <Box flexDirection="column" height={terminalHeight} justifyContent="center" alignItems="center">
272
+ <AddDirectoryDialog
273
+ state={state.addDirectoryDialog}
274
+ onUpdate={(updates) => dispatch(updateAddDirectoryDialog(updates))}
275
+ onConfirm={handleAddDirConfirm}
276
+ onCancel={() => dispatch(hideAddDirectoryDialog())}
277
+ />
278
+ </Box>
279
+ );
280
+ }
281
+
337
282
  // Show filter options overlay
338
283
  if (mode === "filter-options") {
339
284
  return (
@@ -388,7 +388,7 @@ export function UnifiedProjectItem({ repo, isSelected, isCursor }: UnifiedProjec
388
388
  <Text backgroundColor={bg}>{' '}</Text>
389
389
 
390
390
  {/* Sync status */}
391
- <Text color={isCursor ? fg : sync.color} backgroundColor={sync.bgColor || bg} bold={!!sync.bgColor}>
391
+ <Text color={isCursor ? fg : sync.color} backgroundColor={bg}>
392
392
  {sync.text}
393
393
  </Text>
394
394
 
@@ -255,66 +255,66 @@ describe("Directory Browser Utility Functions", () => {
255
255
 
256
256
  // These tests require specific platform or permission setups
257
257
  // Mark them as todo since they're environment-dependent
258
- test.todo("should handle very long paths gracefully");
259
- test.todo("should handle paths with special characters");
260
- test.todo("should handle permission denied errors gracefully");
258
+ test.todo("should handle very long paths gracefully", () => {});
259
+ test.todo("should handle paths with special characters", () => {});
260
+ test.todo("should handle permission denied errors gracefully", () => {});
261
261
  });
262
262
  });
263
263
 
264
264
  describe("Browser State Management", () => {
265
265
  describe("input buffer changes", () => {
266
- test.todo("should reset selection when input changes");
267
- test.todo("should reset scroll offset when input changes");
268
- test.todo("should not reset when typing same character");
266
+ test.todo("should reset selection when input changes", () => {});
267
+ test.todo("should reset scroll offset when input changes", () => {});
268
+ test.todo("should not reset when typing same character", () => {});
269
269
  });
270
270
 
271
271
  describe("currentPath changes", () => {
272
- test.todo("should reset scroll offset when navigating to new directory");
273
- test.todo("should reset selection when navigating to new directory");
274
- test.todo("should reload directory entries for new path");
272
+ test.todo("should reset scroll offset when navigating to new directory", () => {});
273
+ test.todo("should reset selection when navigating to new directory", () => {});
274
+ test.todo("should reload directory entries for new path", () => {});
275
275
  });
276
276
 
277
277
  describe("tab completion", () => {
278
- test.todo("should cycle through completions when pressing Tab repeatedly");
279
- test.todo("should add trailing slash after completing directory");
280
- test.todo("should update currentPath to completed directory");
281
- test.todo("should update folder list to show completed directory's contents");
278
+ test.todo("should cycle through completions when pressing Tab repeatedly", () => {});
279
+ test.todo("should add trailing slash after completing directory", () => {});
280
+ test.todo("should update currentPath to completed directory", () => {});
281
+ test.todo("should update folder list to show completed directory's contents", () => {});
282
282
  });
283
283
 
284
284
  describe("backspace handling", () => {
285
- test.todo("should allow backspacing initial tilde");
286
- test.todo("should allow backspacing to empty input");
287
- test.todo("should reset selection when backspacing");
288
- test.todo("should reset scroll offset when backspacing");
285
+ test.todo("should allow backspacing initial tilde", () => {});
286
+ test.todo("should allow backspacing to empty input", () => {});
287
+ test.todo("should reset selection when backspacing", () => {});
288
+ test.todo("should reset scroll offset when backspacing", () => {});
289
289
  });
290
290
 
291
291
  describe("escape key handling", () => {
292
- test.todo("should go to parent directory when input ends with /");
293
- test.todo("should clear input when input has text beyond ~");
294
- test.todo("should cancel when input is just ~");
295
- test.todo("should reset all state when going back");
292
+ test.todo("should go to parent directory when input ends with /", () => {});
293
+ test.todo("should clear input when input has text beyond ~", () => {});
294
+ test.todo("should cancel when input is just ~", () => {});
295
+ test.todo("should reset all state when going back", () => {});
296
296
  });
297
297
 
298
298
  describe("Ctrl+U handling", () => {
299
- test.todo("should reset input to ~");
300
- test.todo("should reset selection");
301
- test.todo("should reset scroll offset");
302
- test.todo("should clear errors");
299
+ test.todo("should reset input to ~", () => {});
300
+ test.todo("should reset selection", () => {});
301
+ test.todo("should reset scroll offset", () => {});
302
+ test.todo("should clear errors", () => {});
303
303
  });
304
304
 
305
305
  describe("folder filtering logic", () => {
306
- test.todo("should filter by name when input has no slashes");
307
- test.todo("should NOT filter when input starts with /");
308
- test.todo("should NOT filter when input contains /");
309
- test.todo("should show all folders when typing /Volumes");
310
- test.todo("should show all folders when typing ~/code/proj");
306
+ test.todo("should filter by name when input has no slashes", () => {});
307
+ test.todo("should NOT filter when input starts with /", () => {});
308
+ test.todo("should NOT filter when input contains /", () => {});
309
+ test.todo("should show all folders when typing /Volumes", () => {});
310
+ test.todo("should show all folders when typing ~/code/proj", () => {});
311
311
  });
312
312
 
313
313
  describe("dynamic currentPath updates", () => {
314
- test.todo("should update currentPath when input is valid directory");
315
- test.todo("should expand ~ before validating");
316
- test.todo("should reload entries when currentPath changes");
317
- test.todo("should default to startingPath when input is empty");
314
+ test.todo("should update currentPath when input is valid directory", () => {});
315
+ test.todo("should expand ~ before validating", () => {});
316
+ test.todo("should reload entries when currentPath changes", () => {});
317
+ test.todo("should default to startingPath when input is empty", () => {});
318
318
  });
319
319
  });
320
320
 
package/src/constants.ts CHANGED
@@ -14,6 +14,8 @@ export const UI = {
14
14
  MIN_TERMINAL_HEIGHT: 24,
15
15
  /** Default terminal height if detection fails */
16
16
  DEFAULT_TERMINAL_HEIGHT: 24,
17
+ /** Maximum length for command output before truncation */
18
+ OUTPUT_TRUNCATION_LENGTH: 50,
17
19
  } as const;
18
20
 
19
21
  // ============================================================================
package/src/git/index.ts CHANGED
@@ -1,2 +1 @@
1
- export * from "./types.js";
2
- export * from "./service.js";
1
+ export * from "./types.js";
@@ -369,10 +369,9 @@ export async function cloneGitHubRepo(
369
369
  }
370
370
 
371
371
  // Check if target directory exists
372
- // TODO: Re-enable after fixing test environment
373
- // if (!existsSync(targetDir)) {
374
- // return { success: false, error: `Target directory does not exist: ${targetDir}` };
375
- // }
372
+ if (!existsSync(targetDir)) {
373
+ return { success: false, error: `Target directory does not exist: ${targetDir}` };
374
+ }
376
375
 
377
376
  const repoPath = `${targetDir}/${repo.name}`;
378
377
  const url = useSSH ? repo.github.sshUrl : repo.github.cloneUrl;
@@ -15,7 +15,7 @@ export interface BackgroundFetchDeps {
15
15
 
16
16
  export function useBackgroundFetch(
17
17
  config: GitforestConfig,
18
- onRefresh: () => Promise<void>,
18
+ onRefresh: (options?: { forceRefresh?: boolean }) => Promise<void>,
19
19
  deps?: Partial<BackgroundFetchDeps>,
20
20
  ) {
21
21
  const { state, dispatch } = useStore();
@@ -7,7 +7,7 @@ import type { GitforestConfig, Project } from "../types/index.ts";
7
7
 
8
8
  interface UseConfirmDialogActionsOptions {
9
9
  config: GitforestConfig;
10
- onRefresh: () => Promise<void>;
10
+ onRefresh: (options?: { forceRefresh?: boolean }) => Promise<void>;
11
11
  }
12
12
 
13
13
  export function useConfirmDialogActions({