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
package/src/scanner/index.ts
CHANGED
|
@@ -90,6 +90,10 @@ async function scanDirectory(
|
|
|
90
90
|
// Check if this directory exists
|
|
91
91
|
if (!existsSync(dirPath)) return;
|
|
92
92
|
|
|
93
|
+
// The configured root directory (depth 0) is always a container — we must
|
|
94
|
+
// recurse into it even if it happens to have a project marker or .git.
|
|
95
|
+
const isRootDir = depth === 0;
|
|
96
|
+
|
|
93
97
|
// Check if this is a git repository
|
|
94
98
|
const isGit = await gitService.isGitRepo(dirPath);
|
|
95
99
|
|
|
@@ -110,20 +114,24 @@ async function scanDirectory(
|
|
|
110
114
|
}
|
|
111
115
|
}
|
|
112
116
|
|
|
113
|
-
// Don't recurse into git repositories
|
|
114
|
-
|
|
117
|
+
// Don't recurse into git repositories — except the root scan directory,
|
|
118
|
+
// which the user explicitly configured as a container to scan inside.
|
|
119
|
+
if (!isRootDir) return;
|
|
115
120
|
}
|
|
116
121
|
|
|
117
122
|
// Check if this is a project without git
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
123
|
+
if (!isGit) {
|
|
124
|
+
const marker = await detectProjectMarker(dirPath);
|
|
125
|
+
if (marker) {
|
|
126
|
+
// At the root level, skip adding it as a project — it's a container, not
|
|
127
|
+
// a project the user wants to manage. Still recurse into children.
|
|
128
|
+
if (!isRootDir && config.display.showNonGitProjects) {
|
|
129
|
+
const project = await createProject(dirPath, "non-git", marker, gitService);
|
|
130
|
+
foundProjects.push(project);
|
|
131
|
+
}
|
|
132
|
+
// Don't recurse into non-git projects (except the root scan directory)
|
|
133
|
+
if (!isRootDir) return;
|
|
124
134
|
}
|
|
125
|
-
// Don't recurse into non-git projects (even if not showing them)
|
|
126
|
-
return;
|
|
127
135
|
}
|
|
128
136
|
|
|
129
137
|
// This is just a directory - scan children
|
|
@@ -293,20 +301,18 @@ function projectToDbRow(project: Project) {
|
|
|
293
301
|
}
|
|
294
302
|
|
|
295
303
|
/**
|
|
296
|
-
* Save projects to the cache database
|
|
304
|
+
* Save projects to the cache database.
|
|
305
|
+
* Replaces all cached projects so removed directories, depth changes, etc.
|
|
306
|
+
* are reflected immediately.
|
|
297
307
|
*/
|
|
298
308
|
async function saveToCache(projects: Project[]): Promise<void> {
|
|
299
309
|
const db = await initDb();
|
|
300
310
|
|
|
311
|
+
// Clear old entries then insert fresh scan results
|
|
312
|
+
await db.delete(schema.projects).run();
|
|
301
313
|
for (const project of projects) {
|
|
302
314
|
const row = projectToDbRow(project);
|
|
303
|
-
await db
|
|
304
|
-
.insert(schema.projects)
|
|
305
|
-
.values(row)
|
|
306
|
-
.onConflictDoUpdate({
|
|
307
|
-
target: schema.projects.path,
|
|
308
|
-
set: row,
|
|
309
|
-
});
|
|
315
|
+
await db.insert(schema.projects).values(row);
|
|
310
316
|
}
|
|
311
317
|
}
|
|
312
318
|
|
|
@@ -368,8 +374,16 @@ export async function scanWithCache(
|
|
|
368
374
|
// Try to load from cache first
|
|
369
375
|
if (!forceRefresh) {
|
|
370
376
|
try {
|
|
371
|
-
const
|
|
372
|
-
|
|
377
|
+
const allCached = await loadFromCache();
|
|
378
|
+
// Filter to only projects under currently configured directories
|
|
379
|
+
const cached = allCached.filter(p =>
|
|
380
|
+
config.directories.some(dir => p.path.startsWith(dir.path))
|
|
381
|
+
);
|
|
382
|
+
// Also verify all configured directories are represented in cache
|
|
383
|
+
const allDirsCovered = config.directories.every(dir =>
|
|
384
|
+
cached.some(p => p.path.startsWith(dir.path))
|
|
385
|
+
);
|
|
386
|
+
if (allDirsCovered && isCacheFresh(cached, config.cache.ttlSeconds)) {
|
|
373
387
|
return cached;
|
|
374
388
|
}
|
|
375
389
|
} catch {
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { GitforestConfig, DirectoryConfig } from "../types/index.ts";
|
|
2
|
+
import { errorToString } from "../utils/errors.ts";
|
|
3
|
+
|
|
4
|
+
/** Known terminal-based editors that need raw mode handling */
|
|
5
|
+
const TERMINAL_EDITORS = ["vim", "nvim", "nano", "vi", "emacs", "hx", "helix"];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve which editor to use for a given project path.
|
|
9
|
+
* Priority: directory-specific > global config > $EDITOR env > "code"
|
|
10
|
+
*/
|
|
11
|
+
export function resolveEditor(
|
|
12
|
+
projectPath: string,
|
|
13
|
+
globalEditor?: string,
|
|
14
|
+
directories?: DirectoryConfig[]
|
|
15
|
+
): string {
|
|
16
|
+
// Check for directory-specific editor
|
|
17
|
+
if (directories) {
|
|
18
|
+
const matchedDir = directories.find(d =>
|
|
19
|
+
projectPath.startsWith(d.path.replace(/^~/, process.env.HOME || ""))
|
|
20
|
+
);
|
|
21
|
+
if (matchedDir?.editor) {
|
|
22
|
+
return matchedDir.editor;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return globalEditor || process.env.EDITOR || "code";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Open a file/directory in the configured editor.
|
|
30
|
+
* Handles terminal vs GUI editors, macOS optimizations.
|
|
31
|
+
*/
|
|
32
|
+
export async function openInEditor(
|
|
33
|
+
projectPath: string,
|
|
34
|
+
config: GitforestConfig
|
|
35
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
36
|
+
const editor = resolveEditor(projectPath, config.editor, config.directories);
|
|
37
|
+
|
|
38
|
+
// Split command and args once
|
|
39
|
+
const parts = editor.split(" ");
|
|
40
|
+
const cmd = parts[0]!;
|
|
41
|
+
const args = parts.slice(1);
|
|
42
|
+
|
|
43
|
+
// Check if it's a known terminal editor
|
|
44
|
+
const isTerminal = TERMINAL_EDITORS.includes(cmd);
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
if (isTerminal) {
|
|
48
|
+
// Suspends Ink's raw mode to allow the editor to take over
|
|
49
|
+
if (process.stdin.setRawMode) {
|
|
50
|
+
process.stdin.setRawMode(false);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Spawn with inherit to take over terminal
|
|
54
|
+
const proc = Bun.spawn([cmd, ...args, projectPath], {
|
|
55
|
+
stdin: "inherit",
|
|
56
|
+
stdout: "inherit",
|
|
57
|
+
stderr: "inherit",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await proc.exited;
|
|
61
|
+
|
|
62
|
+
// Resume raw mode
|
|
63
|
+
if (process.stdin.setRawMode) {
|
|
64
|
+
process.stdin.setRawMode(true);
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
// GUI Editor
|
|
68
|
+
|
|
69
|
+
// Use 'open' command for macOS GUI editors (VS Code, Cursor) specific optimization
|
|
70
|
+
if (process.platform === "darwin" && (cmd === "code" || cmd === "cursor")) {
|
|
71
|
+
const appName = cmd === "code" ? "Visual Studio Code" : "Cursor";
|
|
72
|
+
|
|
73
|
+
const openArgs = ["open", "-a", appName, projectPath];
|
|
74
|
+
openArgs.push("--args", "-n"); // Force new window
|
|
75
|
+
|
|
76
|
+
const subprocess = Bun.spawn(openArgs, {
|
|
77
|
+
stdin: "ignore",
|
|
78
|
+
stdout: "ignore",
|
|
79
|
+
stderr: "ignore",
|
|
80
|
+
});
|
|
81
|
+
subprocess.unref();
|
|
82
|
+
} else {
|
|
83
|
+
// Fallback for other GUI editors
|
|
84
|
+
|
|
85
|
+
// Auto-inject -n for code/cursor if not using 'open' strategy or on other platforms
|
|
86
|
+
if ((cmd === "code" || cmd === "cursor") && !args.includes("-n") && !args.includes("--new-window")) {
|
|
87
|
+
args.push("-n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const subprocess = Bun.spawn([cmd, ...args, projectPath], {
|
|
91
|
+
stdin: "ignore",
|
|
92
|
+
stdout: "ignore",
|
|
93
|
+
stderr: "ignore",
|
|
94
|
+
});
|
|
95
|
+
subprocess.unref();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { success: true };
|
|
99
|
+
} catch (error) {
|
|
100
|
+
// Ensure raw mode is back if we failed mid-flight
|
|
101
|
+
if (process.stdin.setRawMode) process.stdin.setRawMode(true);
|
|
102
|
+
return { success: false, error: errorToString(error) };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Open a URL in the default browser.
|
|
108
|
+
*/
|
|
109
|
+
export async function openInBrowser(
|
|
110
|
+
url: string
|
|
111
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
112
|
+
try {
|
|
113
|
+
Bun.spawn(["open", url], {
|
|
114
|
+
stdout: "ignore",
|
|
115
|
+
stderr: "ignore",
|
|
116
|
+
});
|
|
117
|
+
return { success: true };
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return { success: false, error: errorToString(error) };
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/state/actions.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AppMode, SortField, SortDirection, Project, ViewMode, GitHubRepoInfo, UnifiedRepo, QuickFilter, DetailModalState } from "../types/index.ts";
|
|
1
|
+
import type { AppMode, SortField, SortDirection, Project, ViewMode, GitHubRepoInfo, UnifiedRepo, QuickFilter, DetailModalState, AddDirectoryDialogState, DirectoryConfig } from "../types/index.ts";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Action creators for app state
|
|
@@ -112,7 +112,7 @@ export const setGitHubError = (error: string | null) => ({
|
|
|
112
112
|
payload: error,
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
-
export const showCloneDialog = (repos: UnifiedRepo[], directories:
|
|
115
|
+
export const showCloneDialog = (repos: UnifiedRepo[], directories: DirectoryConfig[], selectedDirIndex: number, useSSH: boolean) => ({
|
|
116
116
|
type: "SHOW_CLONE_DIALOG" as const,
|
|
117
117
|
payload: { repos, directories, selectedDirIndex, useSSH },
|
|
118
118
|
});
|
|
@@ -173,3 +173,24 @@ export const setLanguageFilter = (language: string | null) => ({
|
|
|
173
173
|
type: "SET_LANGUAGE_FILTER" as const,
|
|
174
174
|
payload: language,
|
|
175
175
|
});
|
|
176
|
+
|
|
177
|
+
// Add directory dialog actions
|
|
178
|
+
export const showAddDirectoryDialog = () => ({
|
|
179
|
+
type: "SHOW_ADD_DIRECTORY_DIALOG" as const,
|
|
180
|
+
payload: {
|
|
181
|
+
step: "browse",
|
|
182
|
+
selectedPath: null,
|
|
183
|
+
maxDepthInput: "2",
|
|
184
|
+
labelInput: "",
|
|
185
|
+
error: null,
|
|
186
|
+
} satisfies AddDirectoryDialogState,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
export const hideAddDirectoryDialog = () => ({
|
|
190
|
+
type: "HIDE_ADD_DIRECTORY_DIALOG" as const,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
export const updateAddDirectoryDialog = (updates: Partial<AddDirectoryDialogState>) => ({
|
|
194
|
+
type: "UPDATE_ADD_DIRECTORY_DIALOG" as const,
|
|
195
|
+
payload: updates,
|
|
196
|
+
});
|
package/src/state/reducer.ts
CHANGED
|
@@ -32,6 +32,7 @@ export const initialState: UnifiedAppState = {
|
|
|
32
32
|
isRefreshing: false,
|
|
33
33
|
cloneDialog: null,
|
|
34
34
|
detailModal: null,
|
|
35
|
+
addDirectoryDialog: null,
|
|
35
36
|
languageFilter: null,
|
|
36
37
|
};
|
|
37
38
|
|
|
@@ -288,6 +289,28 @@ export function appReducer(state: UnifiedAppState, action: UnifiedAppAction): Un
|
|
|
288
289
|
cursorIndex: 0,
|
|
289
290
|
};
|
|
290
291
|
|
|
292
|
+
case "SHOW_ADD_DIRECTORY_DIALOG":
|
|
293
|
+
return {
|
|
294
|
+
...state,
|
|
295
|
+
mode: "add-directory",
|
|
296
|
+
addDirectoryDialog: action.payload,
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
case "HIDE_ADD_DIRECTORY_DIALOG":
|
|
300
|
+
return {
|
|
301
|
+
...state,
|
|
302
|
+
mode: "normal",
|
|
303
|
+
addDirectoryDialog: null,
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
case "UPDATE_ADD_DIRECTORY_DIALOG":
|
|
307
|
+
return {
|
|
308
|
+
...state,
|
|
309
|
+
addDirectoryDialog: state.addDirectoryDialog
|
|
310
|
+
? { ...state.addDirectoryDialog, ...action.payload }
|
|
311
|
+
: null,
|
|
312
|
+
};
|
|
313
|
+
|
|
291
314
|
default:
|
|
292
315
|
return state;
|
|
293
316
|
}
|
package/src/types/index.ts
CHANGED
|
@@ -190,7 +190,7 @@ export type SortField =
|
|
|
190
190
|
| "lastActivity"
|
|
191
191
|
| "size";
|
|
192
192
|
export type SortDirection = "asc" | "desc";
|
|
193
|
-
export type AppMode = "normal" | "filter" | "action" | "help" | "confirm" | "clone" | "detail" | "filter-options" | "command-palette";
|
|
193
|
+
export type AppMode = "normal" | "filter" | "action" | "help" | "confirm" | "clone" | "detail" | "filter-options" | "command-palette" | "add-directory";
|
|
194
194
|
|
|
195
195
|
// Quick filter for status-based filtering (1=dirty, 2=unpushed, 3=no-remote, 0=all)
|
|
196
196
|
export type QuickFilter =
|
|
@@ -214,6 +214,14 @@ export interface CloneDialogState {
|
|
|
214
214
|
useSSH: boolean;
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
export interface AddDirectoryDialogState {
|
|
218
|
+
step: "browse" | "maxDepth" | "label";
|
|
219
|
+
selectedPath: string | null;
|
|
220
|
+
maxDepthInput: string;
|
|
221
|
+
labelInput: string;
|
|
222
|
+
error: string | null;
|
|
223
|
+
}
|
|
224
|
+
|
|
217
225
|
export interface DetailModalState {
|
|
218
226
|
repo: UnifiedRepo;
|
|
219
227
|
readmeContent: string | null;
|
|
@@ -360,6 +368,7 @@ export interface UnifiedAppState extends AppState {
|
|
|
360
368
|
isRefreshing: boolean; // Background refresh indicator
|
|
361
369
|
cloneDialog: CloneDialogState | null;
|
|
362
370
|
detailModal: DetailModalState | null;
|
|
371
|
+
addDirectoryDialog: AddDirectoryDialogState | null;
|
|
363
372
|
languageFilter: string | null;
|
|
364
373
|
}
|
|
365
374
|
|
|
@@ -380,4 +389,7 @@ export type UnifiedAppAction =
|
|
|
380
389
|
| { type: "SHOW_DETAIL_MODAL"; payload: DetailModalState }
|
|
381
390
|
| { type: "HIDE_DETAIL_MODAL" }
|
|
382
391
|
| { type: "UPDATE_DETAIL_MODAL"; payload: Partial<DetailModalState> }
|
|
383
|
-
| { type: "SET_LANGUAGE_FILTER"; payload: string | null }
|
|
392
|
+
| { type: "SET_LANGUAGE_FILTER"; payload: string | null }
|
|
393
|
+
| { type: "SHOW_ADD_DIRECTORY_DIALOG"; payload: AddDirectoryDialogState }
|
|
394
|
+
| { type: "HIDE_ADD_DIRECTORY_DIALOG" }
|
|
395
|
+
| { type: "UPDATE_ADD_DIRECTORY_DIALOG"; payload: Partial<AddDirectoryDialogState> };
|