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.
@@ -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 (except for submodules which we handle above)
114
- return;
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
- const marker = await detectProjectMarker(dirPath);
119
- if (marker) {
120
- // Only add non-git projects if configured to show them
121
- if (config.display.showNonGitProjects) {
122
- const project = await createProject(dirPath, "non-git", marker, gitService);
123
- foundProjects.push(project);
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 cached = await loadFromCache();
372
- if (isCacheFresh(cached, config.cache.ttlSeconds)) {
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
+ }
@@ -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: any[], selectedDirIndex: number, useSSH: boolean) => ({
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
+ });
@@ -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
  }
@@ -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> };