/** * Project discovery. * * A project is a directory under the root containing a `.sl0ppi/` marker. * Depth-1 by default: recursively scanning ~/dev is slow and surprising, and * an explicit `sl0ppi open path/to/nested` covers the rest. */ import { readdirSync, statSync } from "node:fs"; import { join } from "node:path"; export const MARKER_DIR = ".sl0ppi"; export const LAYOUT_FILE = "layout.kdl"; export interface DiscoverOptions { /** How many directory levels below the root to search. Default 1. */ depth?: number; } /** * Paths of discovered projects, relative to `root`, sorted alphabetically. * * Unreadable directories are skipped rather than fatal: one bad-permission * directory in ~/dev must not stop the other eleven projects from opening. */ export function discoverProjects(root: string, options: DiscoverOptions = {}): string[] { const depth = options.depth ?? 1; const found: string[] = []; walk(root, "", depth, found); return found.sort(); } function walk(root: string, rel: string, depthLeft: number, found: string[]): void { if (depthLeft <= 0) return; let entries; try { entries = readdirSync(join(root, rel), { withFileTypes: true }); } catch { return; // unreadable: skip, do not throw } for (const entry of entries) { // Skip dotfiles: `.git` and friends are never projects, and this also // stops us descending into the marker directory itself. if (entry.name.startsWith(".")) continue; const childRel = rel ? `${rel}/${entry.name}` : entry.name; const childAbs = join(root, childRel); // Follow symlinked directories (a worktree or a symlinked checkout is a // perfectly ordinary way to lay out ~/dev), but never fail on a broken one. let isDir = entry.isDirectory(); if (!isDir && entry.isSymbolicLink()) { try { isDir = statSync(childAbs).isDirectory(); } catch { continue; // dangling symlink } } if (!isDir) continue; if (isProjectDir(childAbs)) { found.push(childRel); continue; // a project is a leaf; do not nest projects inside projects } walk(root, childRel, depthLeft - 1, found); } } /** A directory is a project when it carries the `.sl0ppi/` marker directory. */ export function isProjectDir(absPath: string): boolean { try { return statSync(join(absPath, MARKER_DIR)).isDirectory(); } catch { return false; } }