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.
@@ -1,4 +1,6 @@
1
+ import React from "react";
1
2
  import { useInput, useApp } from "ink";
3
+ import type { Key } from "ink";
2
4
  import { useStore, useFilteredProjects, useSelectedProjects, useFilteredUnifiedRepos, useSelectedUnifiedRepos } from "../state/store.tsx";
3
5
  import {
4
6
  moveCursor,
@@ -13,12 +15,14 @@ import {
13
15
  startAction,
14
16
  endAction,
15
17
  updateProgress,
18
+ showAddDirectoryDialog,
16
19
  } from "../state/actions.ts";
17
20
  import { batchPull as defaultBatchPull, batchPush as defaultBatchPush, batchFetch as defaultBatchFetch } from "../operations/batch.ts";
18
21
  import { initGitInProject as defaultInitGitInProject } from "../git/operations.ts";
19
22
  import { executeCommand as defaultExecuteCommand, findCommandByKey as defaultFindCommandByKey } from "../operations/commands.ts";
20
23
  import { errorToString } from "../utils/errors.ts";
21
- import type { GitforestConfig, ConfirmDialogState, QuickFilter, ViewMode, DetailModalState, UnifiedRepo, CommandConfig, Project, BatchResult, OperationResult } from "../types/index.ts";
24
+ import { UI } from "../constants.ts";
25
+ import type { GitforestConfig, ConfirmDialogState, QuickFilter, ViewMode, DetailModalState, UnifiedRepo, UnifiedAppState, UnifiedAppAction, CommandConfig, Project, BatchResult, OperationResult } from "../types/index.ts";
22
26
 
23
27
  /**
24
28
  * Dependencies that can be injected for testing
@@ -34,10 +38,151 @@ export interface KeyBindingDeps {
34
38
 
35
39
  interface UseKeyBindingsOptions {
36
40
  config: GitforestConfig;
37
- onRefresh: () => Promise<void>;
41
+ onRefresh: (options?: { forceRefresh?: boolean }) => Promise<void>;
38
42
  deps?: Partial<KeyBindingDeps>;
39
43
  }
40
44
 
45
+ /**
46
+ * Handle input in filter mode.
47
+ * Returns true if input was handled.
48
+ */
49
+ function handleFilterMode(
50
+ key: Key,
51
+ dispatch: React.Dispatch<UnifiedAppAction>,
52
+ ): boolean {
53
+ if (key.escape) {
54
+ dispatch(setMode("normal"));
55
+ dispatch(setFilter(""));
56
+ return true;
57
+ }
58
+ if (key.return) {
59
+ dispatch(setMode("normal"));
60
+ return true;
61
+ }
62
+ return true; // Let TextInput handle other keys
63
+ }
64
+
65
+ /**
66
+ * Handle input in detail modal mode.
67
+ * Returns true if input was fully handled, false if it should fall through to normal mode.
68
+ */
69
+ async function handleDetailMode(
70
+ input: string,
71
+ key: Key,
72
+ state: UnifiedAppState,
73
+ dispatch: React.Dispatch<UnifiedAppAction>,
74
+ ): Promise<boolean> {
75
+ if (key.escape) {
76
+ dispatch({ type: "HIDE_DETAIL_MODAL" });
77
+ return true;
78
+ }
79
+ // j/k to scroll README
80
+ if (input === "j") {
81
+ dispatch({ type: "UPDATE_DETAIL_MODAL", payload: {
82
+ readmeScrollOffset: (state.detailModal?.readmeScrollOffset ?? 0) + 1,
83
+ }});
84
+ return true;
85
+ }
86
+ if (input === "k") {
87
+ dispatch({ type: "UPDATE_DETAIL_MODAL", payload: {
88
+ readmeScrollOffset: Math.max(0, (state.detailModal?.readmeScrollOffset ?? 0) - 1),
89
+ }});
90
+ return true;
91
+ }
92
+ // Actions that close modal and fall through to normal handling
93
+ if (input === "p" || input === "P" || input === "f") {
94
+ dispatch({ type: "HIDE_DETAIL_MODAL" });
95
+ return false; // Let input fall through to normal mode handling
96
+ }
97
+ if (input === "o") {
98
+ const repo = state.detailModal?.repo;
99
+ if (repo?.github?.htmlUrl) {
100
+ try {
101
+ await Bun.$`open ${repo.github.htmlUrl}`.quiet();
102
+ } catch (error) {
103
+ dispatch(setMessage(`Failed to open browser: ${errorToString(error)}`));
104
+ }
105
+ }
106
+ return true;
107
+ }
108
+ if (input === "d") {
109
+ const repo = state.detailModal?.repo;
110
+ if (repo?.localPath) {
111
+ const editor = process.env.EDITOR || "code";
112
+ try {
113
+ await Bun.$`${editor} ${repo.localPath}`.quiet();
114
+ } catch (error) {
115
+ dispatch(setMessage(`Failed to open editor: ${errorToString(error)}`));
116
+ }
117
+ }
118
+ return true;
119
+ }
120
+ return true; // Don't process other keys in detail mode
121
+ }
122
+
123
+ /**
124
+ * Handle input in filter options mode.
125
+ * Any key closes the overlay.
126
+ */
127
+ function handleFilterOptionsMode(
128
+ dispatch: React.Dispatch<UnifiedAppAction>,
129
+ ): void {
130
+ dispatch(setMode("normal"));
131
+ }
132
+
133
+ /**
134
+ * Handle input in help mode.
135
+ * Any key closes the help overlay.
136
+ */
137
+ function handleHelpMode(
138
+ dispatch: React.Dispatch<UnifiedAppAction>,
139
+ ): void {
140
+ dispatch(setMode("normal"));
141
+ }
142
+
143
+ /**
144
+ * Handle input in clone dialog mode.
145
+ * Returns true if input was handled.
146
+ */
147
+ function handleCloneMode(
148
+ key: Key,
149
+ dispatch: React.Dispatch<UnifiedAppAction>,
150
+ ): boolean {
151
+ if (key.escape) {
152
+ dispatch({ type: "HIDE_CLONE_DIALOG" });
153
+ dispatch(setMode("normal"));
154
+ return true;
155
+ }
156
+ // Let CloneDialog handle other keys
157
+ return true;
158
+ }
159
+
160
+ /**
161
+ * Handle input in command palette mode.
162
+ * Returns true if input was handled.
163
+ */
164
+ async function handleCommandPaletteMode(
165
+ input: string,
166
+ key: Key,
167
+ config: GitforestConfig,
168
+ dispatch: React.Dispatch<UnifiedAppAction>,
169
+ selectedUnifiedRepos: UnifiedRepo[],
170
+ findCommandByKey: (commands: CommandConfig[], key: string) => CommandConfig | undefined,
171
+ handleCommandExecution: (command: CommandConfig, repos: UnifiedRepo[]) => Promise<void>,
172
+ ): Promise<boolean> {
173
+ if (key.escape) {
174
+ dispatch(setMode("normal"));
175
+ return true;
176
+ }
177
+ const command = findCommandByKey(config.commands, input);
178
+ if (command) {
179
+ await handleCommandExecution(command, selectedUnifiedRepos);
180
+ dispatch(setMode("normal"));
181
+ return true;
182
+ }
183
+ return true;
184
+ }
185
+
41
186
  export function useKeyBindings({ config, onRefresh, deps }: UseKeyBindingsOptions) {
42
187
  const { state, dispatch } = useStore();
43
188
  const { exit } = useApp();
@@ -84,8 +229,8 @@ export function useKeyBindings({ config, onRefresh, deps }: UseKeyBindingsOption
84
229
  if (result.success) {
85
230
  if (result.output) {
86
231
  // Show truncated output
87
- const shortOutput = result.output.length > 50
88
- ? result.output.slice(0, 50) + "..."
232
+ const shortOutput = result.output.length > UI.OUTPUT_TRUNCATION_LENGTH
233
+ ? result.output.slice(0, UI.OUTPUT_TRUNCATION_LENGTH) + "..."
89
234
  : result.output;
90
235
  dispatch(setMessage(`${command.name}: ${shortOutput}`));
91
236
  } else {
@@ -96,7 +241,7 @@ export function useKeyBindings({ config, onRefresh, deps }: UseKeyBindingsOption
96
241
  }
97
242
  } catch (error) {
98
243
  dispatch(endAction());
99
- dispatch(setMessage(`${command.name} failed: ${error instanceof Error ? error.message : String(error)}`));
244
+ dispatch(setMessage(`${command.name} failed: ${errorToString(error)}`));
100
245
  }
101
246
  }
102
247
 
@@ -111,9 +256,11 @@ export function useKeyBindings({ config, onRefresh, deps }: UseKeyBindingsOption
111
256
  const content = await file.text();
112
257
  return { content, error: null };
113
258
  }
114
- } catch {}
259
+ } catch {
260
+ // Expected: README may not exist locally
261
+ }
115
262
  }
116
-
263
+
117
264
  // Try GitHub API
118
265
  if (repo.github) {
119
266
  try {
@@ -130,125 +277,25 @@ export function useKeyBindings({ config, onRefresh, deps }: UseKeyBindingsOption
130
277
  const content = await response.text();
131
278
  return { content, error: null };
132
279
  }
133
- } catch {}
280
+ } catch {
281
+ // Expected: repo may not have a README on GitHub
282
+ }
134
283
  }
135
-
284
+
136
285
  return { content: null, error: "README not found" };
137
286
  }
138
287
 
139
288
  useInput(async (input, key) => {
140
289
  const { mode, cursorIndex, selectedIndices } = state;
141
290
 
142
- // Handle filter mode separately
143
- if (mode === "filter") {
144
- if (key.escape) {
145
- dispatch(setMode("normal"));
146
- dispatch(setFilter(""));
147
- return;
148
- }
149
- if (key.return) {
150
- dispatch(setMode("normal"));
151
- return;
152
- }
153
- // Let TextInput handle other keys
154
- return;
155
- }
156
-
157
- // Handle detail modal mode
158
- if (mode === "detail") {
159
- if (key.escape) {
160
- dispatch({ type: "HIDE_DETAIL_MODAL" });
161
- return;
162
- }
163
- // j/k to scroll README
164
- if (input === "j") {
165
- dispatch({ type: "UPDATE_DETAIL_MODAL", payload: {
166
- readmeScrollOffset: (state.detailModal?.readmeScrollOffset ?? 0) + 1
167
- }});
168
- return;
169
- }
170
- if (input === "k") {
171
- dispatch({ type: "UPDATE_DETAIL_MODAL", payload: {
172
- readmeScrollOffset: Math.max(0, (state.detailModal?.readmeScrollOffset ?? 0) - 1)
173
- }});
174
- return;
175
- }
176
- // Actions in modal - these should trigger the onAction callback
177
- // For now, just close modal and perform action
178
- if (input === "p" || input === "P" || input === "f") {
179
- // Let the existing handlers work, but close modal first
180
- dispatch({ type: "HIDE_DETAIL_MODAL" });
181
- // Don't return - let it fall through to normal handling
182
- }
183
- if (input === "o") {
184
- // Open in browser - need to implement
185
- const repo = state.detailModal?.repo;
186
- if (repo?.github?.htmlUrl) {
187
- // Use Bun to open URL
188
- try {
189
- await Bun.$`open ${repo.github.htmlUrl}`.quiet();
190
- } catch (error) {
191
- dispatch(setMessage(`Failed to open browser: ${errorToString(error)}`));
192
- }
193
- }
194
- return;
195
- }
196
- if (input === "d") {
197
- // Open in editor
198
- const repo = state.detailModal?.repo;
199
- if (repo?.localPath) {
200
- const editor = process.env.EDITOR || "code";
201
- try {
202
- await Bun.$`${editor} ${repo.localPath}`.quiet();
203
- } catch (error) {
204
- dispatch(setMessage(`Failed to open editor: ${errorToString(error)}`));
205
- }
206
- }
207
- return;
208
- }
209
- return; // Don't process other keys in detail mode
210
- }
211
-
212
- // Handle filter options mode
213
- if (mode === "filter-options") {
214
- // Any key closes the overlay
215
- dispatch(setMode("normal"));
216
- return;
217
- }
218
-
219
- // Handle help mode
220
- if (mode === "help") {
221
- dispatch(setMode("normal"));
222
- return;
223
- }
224
-
225
- // Handle clone dialog mode
226
- if (mode === "clone") {
227
- if (key.escape) {
228
- dispatch({ type: "HIDE_CLONE_DIALOG" });
229
- dispatch(setMode("normal"));
230
- return;
231
- }
232
- // Let CloneDialog handle other keys
233
- return;
234
- }
235
-
236
- // Handle command palette mode
237
- if (mode === "command-palette") {
238
- if (key.escape) {
239
- dispatch(setMode("normal"));
240
- return;
241
- }
242
-
243
- // Find and execute command by key
244
- const command = findCommandByKey(config.commands, input);
245
- if (command) {
246
- await handleCommandExecution(command, selectedUnifiedRepos);
247
- dispatch(setMode("normal"));
248
- return;
249
- }
250
- return;
251
- }
291
+ // Dispatch to mode-specific handlers
292
+ if (mode === "filter" && handleFilterMode(key, dispatch)) return;
293
+ if (mode === "detail" && await handleDetailMode(input, key, state, dispatch)) return;
294
+ if (mode === "filter-options") { handleFilterOptionsMode(dispatch); return; }
295
+ if (mode === "help") { handleHelpMode(dispatch); return; }
296
+ if (mode === "clone" && handleCloneMode(key, dispatch)) return;
297
+ if (mode === "add-directory") return; // Let AddDirectoryDialog handle all keys
298
+ if (mode === "command-palette" && await handleCommandPaletteMode(input, key, config, dispatch, selectedUnifiedRepos, findCommandByKey, handleCommandExecution)) return;
252
299
 
253
300
  // Global keys that work in any mode
254
301
  if (input === "q" || (key.ctrl && input === "c")) {
@@ -280,8 +327,7 @@ export function useKeyBindings({ config, onRefresh, deps }: UseKeyBindingsOption
280
327
  // Tab - cycle view mode
281
328
  if (key.tab) {
282
329
  const modes: ViewMode[] = ["local", "github", "combined"];
283
- const unifiedState = state as any;
284
- const currentMode: ViewMode = unifiedState.viewMode || "local";
330
+ const currentMode: ViewMode = state.viewMode || "local";
285
331
  const currentIdx = modes.indexOf(currentMode);
286
332
  if (currentIdx === -1) return; // Safety check
287
333
  const nextIdx = (currentIdx + 1) % modes.length;
@@ -335,8 +381,7 @@ export function useKeyBindings({ config, onRefresh, deps }: UseKeyBindingsOption
335
381
  }
336
382
 
337
383
  if (input === "G") {
338
- const unifiedState = state as any;
339
- const currentViewMode = unifiedState.viewMode || "combined";
384
+ const currentViewMode = state.viewMode || "combined";
340
385
  const itemCount = currentViewMode === "local" ? filteredProjects.length : filteredUnifiedRepos.length;
341
386
  dispatch(moveCursor(itemCount - 1));
342
387
  return;
@@ -349,8 +394,7 @@ export function useKeyBindings({ config, onRefresh, deps }: UseKeyBindingsOption
349
394
  }
350
395
 
351
396
  if (input === "a") {
352
- const unifiedState = state as any;
353
- const currentViewMode = unifiedState.viewMode || "combined";
397
+ const currentViewMode = state.viewMode || "combined";
354
398
  const itemCount = currentViewMode === "local" ? filteredProjects.length : filteredUnifiedRepos.length;
355
399
  if (selectedIndices.size === itemCount) {
356
400
  dispatch(deselectAll());
@@ -412,10 +456,10 @@ export function useKeyBindings({ config, onRefresh, deps }: UseKeyBindingsOption
412
456
  return;
413
457
  }
414
458
 
415
- // Refresh
459
+ // Refresh (force rescan to pick up config/filesystem changes)
416
460
  if (input === "r") {
417
461
  dispatch({ type: "SET_REFRESHING", payload: true });
418
- await onRefresh();
462
+ await onRefresh({ forceRefresh: true });
419
463
  dispatch({ type: "SET_REFRESHING", payload: false });
420
464
  dispatch(setMessage("Refresh complete"));
421
465
  return;
@@ -458,7 +502,7 @@ export function useKeyBindings({ config, onRefresh, deps }: UseKeyBindingsOption
458
502
  }
459
503
 
460
504
  // Refresh after success to reflect latest state
461
- await onRefresh();
505
+ await onRefresh({ forceRefresh: true });
462
506
  return;
463
507
  }
464
508
 
@@ -488,7 +532,7 @@ export function useKeyBindings({ config, onRefresh, deps }: UseKeyBindingsOption
488
532
  dispatch(setMessage(`Pulled ${result.successful} projects successfully`));
489
533
  }
490
534
 
491
- await onRefresh();
535
+ await onRefresh({ forceRefresh: true });
492
536
  return;
493
537
  }
494
538
 
@@ -518,7 +562,7 @@ export function useKeyBindings({ config, onRefresh, deps }: UseKeyBindingsOption
518
562
  dispatch(setMessage(`Fetched ${result.successful} projects`));
519
563
  }
520
564
 
521
- await onRefresh();
565
+ await onRefresh({ forceRefresh: true });
522
566
  return;
523
567
  }
524
568
 
@@ -545,7 +589,7 @@ export function useKeyBindings({ config, onRefresh, deps }: UseKeyBindingsOption
545
589
  );
546
590
 
547
591
  // Refresh after init
548
- await onRefresh();
592
+ await onRefresh({ forceRefresh: true });
549
593
  return;
550
594
  }
551
595
 
@@ -643,6 +687,12 @@ export function useKeyBindings({ config, onRefresh, deps }: UseKeyBindingsOption
643
687
  return;
644
688
  }
645
689
 
690
+ // + key - Add a directory to scan
691
+ if (input === "+") {
692
+ dispatch(showAddDirectoryDialog());
693
+ return;
694
+ }
695
+
646
696
  // Custom commands - check if input matches a configured command key
647
697
  // This allows executing commands directly from main list without command palette
648
698
  if (config.commands.length > 0) {
@@ -120,65 +120,75 @@ export function useUnifiedRepos(config: GitforestConfig) {
120
120
 
121
121
  /**
122
122
  * Main load function - smart loading with cache-first strategy
123
+ * @param options.forceRefresh - Skip cache and perform a full rescan (e.g. after config changes)
123
124
  */
124
- const loadUnifiedRepos = useCallback(async () => {
125
+ const loadUnifiedRepos = useCallback(async (options?: { forceRefresh?: boolean }) => {
126
+ const forceRefresh = options?.forceRefresh ?? false;
127
+
125
128
  // If we already have data, don't show loading state
126
129
  const hasExistingData = state.unifiedRepos.length > 0;
127
-
130
+
128
131
  if (!hasExistingData) {
129
132
  dispatch(setLoading(true));
130
133
  }
131
134
  dispatch({ type: "SET_GITHUB_ERROR", payload: null });
132
135
 
133
136
  try {
134
- // Step 1: Load from cache instantly
135
- const { localProjects, githubRepos, hasCachedData } = await loadFromCacheInstantly();
136
-
137
- if (hasCachedData) {
138
- // We have cached data - refresh in background
139
- hasLoadedRef.current = true;
140
-
141
- // Check if cache is stale and needs refresh
142
- const now = Date.now();
143
- const isLocalStale = localProjects.some(p =>
144
- now - p.lastScanned.getTime() > config.cache.ttlSeconds * 1000
145
- );
146
-
147
- if (isLocalStale) {
148
- // Refresh in background without blocking
149
- refreshInBackground(localProjects, githubRepos);
137
+ // If forceRefresh, skip cache entirely and do a full scan
138
+ if (!forceRefresh) {
139
+ // Step 1: Load from cache instantly
140
+ const { localProjects, githubRepos, hasCachedData } = await loadFromCacheInstantly();
141
+
142
+ if (hasCachedData) {
143
+ // We have cached data - refresh in background
144
+ hasLoadedRef.current = true;
145
+
146
+ // Check if cache is stale or doesn't cover all configured directories
147
+ const now = Date.now();
148
+ const isLocalStale = localProjects.some(p =>
149
+ now - p.lastScanned.getTime() > config.cache.ttlSeconds * 1000
150
+ );
151
+ const allDirsCovered = config.directories.every(dir =>
152
+ localProjects.some(p => p.path.startsWith(dir.path))
153
+ );
154
+
155
+ if (isLocalStale || !allDirsCovered) {
156
+ // Refresh in background without blocking
157
+ refreshInBackground(localProjects, githubRepos);
158
+ }
159
+ return;
150
160
  }
151
- } else {
152
- // No cache - do full load with loading indicator
153
- dispatch(setLoading(true));
154
- dispatch({ type: "SET_GITHUB_LOADING", payload: true });
155
-
156
- const [freshLocalProjects, githubResult] = await Promise.all([
157
- scanWithCache(config, { forceRefresh: true }),
158
- fetchGitHubReposWithCache({
159
- includeArchived: false,
160
- includeForks: true,
161
- includeOrgs: true,
162
- }, config.cache.githubTtlSeconds),
163
- ]);
164
-
165
- const sorted = sortProjects(freshLocalProjects, sortBy, sortDirection);
166
- dispatch(setProjects(sorted));
161
+ }
167
162
 
168
- const unified = createUnifiedView(freshLocalProjects, githubResult.repos);
169
- dispatch({ type: "SET_GITHUB_REPOS", payload: githubResult.repos });
170
- dispatch({ type: "SET_UNIFIED_REPOS", payload: unified });
163
+ // No cache or forced refresh - do full load with loading indicator
164
+ dispatch(setLoading(true));
165
+ dispatch({ type: "SET_GITHUB_LOADING", payload: true });
171
166
 
172
- if (githubResult.error) {
173
- dispatch({ type: "SET_GITHUB_ERROR", payload: githubResult.error });
174
- }
167
+ const [freshLocalProjects, githubResult] = await Promise.all([
168
+ scanWithCache(config, { forceRefresh: true }),
169
+ fetchGitHubReposWithCache({
170
+ includeArchived: false,
171
+ includeForks: true,
172
+ includeOrgs: true,
173
+ }, config.cache.githubTtlSeconds),
174
+ ]);
175
175
 
176
- const localCount = unified.filter(r => r.source === "local" || r.source === "both").length;
177
- const githubOnlyCount = unified.filter(r => r.source === "github").length;
178
- dispatch(setMessage(`Found ${localCount} local, ${githubOnlyCount} GitHub-only repos`));
176
+ const sorted = sortProjects(freshLocalProjects, sortBy, sortDirection);
177
+ dispatch(setProjects(sorted));
179
178
 
180
- hasLoadedRef.current = true;
179
+ const unified = createUnifiedView(freshLocalProjects, githubResult.repos);
180
+ dispatch({ type: "SET_GITHUB_REPOS", payload: githubResult.repos });
181
+ dispatch({ type: "SET_UNIFIED_REPOS", payload: unified });
182
+
183
+ if (githubResult.error) {
184
+ dispatch({ type: "SET_GITHUB_ERROR", payload: githubResult.error });
181
185
  }
186
+
187
+ const localCount = unified.filter(r => r.source === "local" || r.source === "both").length;
188
+ const githubOnlyCount = unified.filter(r => r.source === "github").length;
189
+ dispatch(setMessage(`Found ${localCount} local, ${githubOnlyCount} GitHub-only repos`));
190
+
191
+ hasLoadedRef.current = true;
182
192
  } catch (error) {
183
193
  dispatch(setError(errorToString(error)));
184
194
  } finally {
package/src/index.tsx CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  loginGitHub,
23
23
  logoutGitHub,
24
24
  handleConfigCommand,
25
+ handleDirCommand,
25
26
  } from "./cli/index.ts";
26
27
  import { clearCache, getCacheStats } from "./scanner/index.ts";
27
28
  import type { ViewMode, GitforestConfig } from "./types/index.ts";
@@ -45,6 +46,13 @@ Commands:
45
46
  create-repos Create GitHub repos for projects without remotes
46
47
  archive <repos...> Archive GitHub repositories
47
48
 
49
+ Directory Commands:
50
+ dir List configured directories (alias: dirs)
51
+ dir list List configured directories
52
+ dir add <path> Add a directory to scan
53
+ dir remove <path|index> Remove a directory from config
54
+ dir set <path|index> Update directory settings
55
+
48
56
  GitHub Commands:
49
57
  login Login to GitHub (opens browser)
50
58
  logout Logout from GitHub
@@ -62,13 +70,16 @@ Cache Commands:
62
70
  Options:
63
71
  --init Create default config file
64
72
  --help, -h Show this help message
65
- --json Output as JSON (for list, status, dirty)
73
+ --json Output as JSON (for list, status, dirty, dir list)
66
74
  --verbose, -v Verbose output
67
75
  --filter, -f <text> Filter projects by name/path
68
76
  --public Create public repos (default: private)
69
77
  --local Show local repos only (github command)
70
78
  --combined Show all repos (github command)
71
79
  --target, -t <dir> Target directory for clone
80
+ --max-depth <n> Max scan depth (for dir add/set)
81
+ --label <text> Label for directory (for dir add/set)
82
+ --editor <cmd> Editor override (for dir set)
72
83
 
73
84
  Environment:
74
85
  GITHUB_TOKEN GitHub personal access token for API access
@@ -97,6 +108,7 @@ TUI Keyboard shortcuts:
97
108
  3 Show no-remote projects
98
109
  s Cycle sort
99
110
  v Cycle view (local/github/combined)
111
+ + Add directory to scan
100
112
  r Refresh
101
113
  ? Help
102
114
  q Quit
@@ -106,6 +118,13 @@ Examples:
106
118
  gitforest list # List all local projects
107
119
  gitforest list --json # List as JSON
108
120
  gitforest status # Show local status summary
121
+ gitforest dir # List configured directories
122
+ gitforest dir add ~/work # Add a directory to scan
123
+ gitforest dir add ~/oss --max-depth 3 --label "Open Source"
124
+ gitforest dir remove ~/old # Remove a directory
125
+ gitforest dir remove 2 # Remove by index
126
+ gitforest dir set 1 --max-depth 4 # Update scan depth
127
+ gitforest dir set ~/work --label "Work Projects"
109
128
  gitforest unified-status # Show status with GitHub repos
110
129
  gitforest github # List GitHub repos not cloned
111
130
  gitforest github --combined # List all repos
@@ -170,9 +189,10 @@ function parseArgs(args: string[]): {
170
189
  // Known flags
171
190
  const knownLongFlags = new Set([
172
191
  'init', 'help', 'json', 'verbose', 'filter', 'target',
173
- 'public', 'local', 'combined', 'https', 'max-depth'
192
+ 'public', 'local', 'combined', 'https', 'max-depth',
193
+ 'label', 'editor', 'depth',
174
194
  ]);
175
- const flagsWithValues = new Set(['filter', 'target', 'max-depth']);
195
+ const flagsWithValues = new Set(['filter', 'target', 'max-depth', 'label', 'editor', 'depth']);
176
196
 
177
197
 
178
198
  const shortFlagMap: Record<string, string> = {
@@ -254,16 +274,16 @@ async function runOnboardingWizard(): Promise<void> {
254
274
  };
255
275
 
256
276
  // Render onboarding wizard
257
- const { waitUntilExit, unmount } = render(
277
+ const instance = render(
258
278
  <OnboardingWizard
259
279
  onComplete={handleComplete}
260
280
  onCancel={handleCancel}
261
- onUnmount={unmount}
281
+ onUnmount={() => instance.unmount()}
262
282
  />
263
283
  );
264
284
 
265
285
  // Wait for completion or cancellation
266
- await waitUntilExit();
286
+ await instance.waitUntilExit();
267
287
 
268
288
  if (cancelled) {
269
289
  console.log("\nOnboarding cancelled. Run 'gitforest --init' to create a default config.");
@@ -456,13 +476,34 @@ async function main() {
456
476
  }
457
477
  break;
458
478
 
479
+ case "dir":
480
+ case "dirs":
481
+ case "directories": {
482
+ const label = typeof flags["label"] === "string" ? flags["label"] : undefined;
483
+ const editor = typeof flags["editor"] === "string" ? flags["editor"] : undefined;
484
+ const dirOptions = { ...cliOptions, label, editor };
485
+ if (positional.length === 0) {
486
+ await handleDirCommand("list", [], dirOptions);
487
+ } else {
488
+ await handleDirCommand(positional[0]!, positional.slice(1), dirOptions);
489
+ }
490
+ break;
491
+ }
492
+
459
493
  case "config":
460
494
  if (positional.length === 0) {
461
495
  console.error("Error: Missing config subcommand");
462
496
  console.log("Usage: gitforest config add-dir <path>");
463
497
  process.exit(1);
464
498
  }
465
- await handleConfigCommand(positional[0]!, positional.slice(1), cliOptions);
499
+ // Legacy: redirect config add-dir to dir add
500
+ if (positional[0] === "add-dir") {
501
+ const label = typeof flags["label"] === "string" ? flags["label"] : undefined;
502
+ const editor = typeof flags["editor"] === "string" ? flags["editor"] : undefined;
503
+ await handleDirCommand("add", positional.slice(1), { ...cliOptions, label, editor });
504
+ } else {
505
+ await handleConfigCommand(positional[0]!, positional.slice(1), cliOptions);
506
+ }
466
507
  break;
467
508
 
468
509
  default: