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.
- package/package.json +1 -1
- package/src/app.tsx +57 -1
- package/src/cli/commands/auth.ts +75 -0
- package/src/cli/commands/batch.ts +141 -0
- package/src/cli/commands/dir.ts +285 -0
- package/src/cli/commands/github.ts +238 -0
- package/src/cli/commands/helpers.ts +15 -0
- package/src/cli/commands/list.ts +76 -0
- package/src/cli/commands/setup.ts +86 -0
- package/src/cli/index.ts +7 -582
- package/src/components/AddDirectoryDialog.tsx +151 -0
- package/src/components/HelpOverlay.tsx +1 -0
- package/src/components/Layout.tsx +44 -99
- package/src/components/UnifiedProjectItem.tsx +1 -1
- package/src/components/onboarding/DirectoriesStep.unit.test.ts +34 -34
- package/src/constants.ts +2 -0
- package/src/git/index.ts +1 -2
- package/src/github/unified.ts +3 -4
- package/src/hooks/useBackgroundFetch.ts +1 -1
- package/src/hooks/useConfirmDialogActions.ts +1 -1
- package/src/hooks/useKeyBindings.ts +181 -131
- package/src/hooks/useUnifiedRepos.ts +54 -44
- package/src/index.tsx +48 -7
- package/src/scanner/index.ts +34 -20
- package/src/services/editor.ts +121 -0
- package/src/state/actions.ts +23 -2
- package/src/state/reducer.ts +23 -0
- package/src/types/index.ts +14 -2
- package/src/git/service.ts +0 -539
|
@@ -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
|
|
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 >
|
|
88
|
-
? result.output.slice(0,
|
|
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: ${
|
|
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
|
-
//
|
|
143
|
-
if (mode === "filter")
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
now
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
dispatch(setMessage(`Found ${localCount} local, ${githubOnlyCount} GitHub-only repos`));
|
|
176
|
+
const sorted = sortProjects(freshLocalProjects, sortBy, sortDirection);
|
|
177
|
+
dispatch(setProjects(sorted));
|
|
179
178
|
|
|
180
|
-
|
|
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
|
|
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
|
-
|
|
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:
|