/** * Roots and session names. * * One root directory = one sl0ppij session. The session name is derived from * the root's basename so that typing `sl0ppi` in ~/dev always lands in the same * session instead of minting another randomly-named one. * * The registry (session name -> root path) exists because a tab purpose stores * a path *relative to the root*, and `list-panes --json` exposes no cwd, so * without it there is no way back from a session to a directory. It is a * rebuildable index, not a source of truth: delete it and the next `up --root` * writes it again. It doubles as the record of which sessions are ours, which * is how sl0ppi avoids touching sessions it did not create. */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { homedir } from "node:os"; import { createHash } from "node:crypto"; import { isProjectDir } from "./discover.ts"; export function configDir(env: NodeJS.ProcessEnv = process.env): string { return env.SL0PPI_CONFIG_DIR ?? join(env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "sl0ppi"); } export function registryPath(env?: NodeJS.ProcessEnv): string { return join(configDir(env), "roots.json"); } export type Registry = Record; export function loadRegistry(env?: NodeJS.ProcessEnv): Registry { const path = registryPath(env); try { const parsed = JSON.parse(readFileSync(path, "utf8")); return typeof parsed === "object" && parsed !== null ? (parsed as Registry) : {}; } catch { return {}; // missing or corrupt: it is an index, so rebuild rather than fail } } export function saveRegistry(registry: Registry, env?: NodeJS.ProcessEnv): void { const path = registryPath(env); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(registry, null, 2)}\n`); } /** * Session name for a root. * * Sanitised to something safe to type and to pass as a session name, and * disambiguated with a short hash when another root already claims the name — * two checkouts both called `api` must not fight over one session. */ export function sessionNameFor(root: string, registry: Registry = {}): string { const cleaned = basename(resolve(root)).replace(/[^A-Za-z0-9_-]/g, "-").replace(/^-+|-+$/g, ""); const base = cleaned.length > 0 ? cleaned : "root"; const claimed = registry[base]; if (claimed === undefined || resolve(claimed) === resolve(root)) return base; return `${base}-${createHash("sha256").update(resolve(root)).digest("hex").slice(0, 6)}`; } export interface ResolvedRoot { root: string; session: string; /** Set when the CWD is itself a project, so `sl0ppi` can focus its tab. */ project?: string; } /** * Work out which root the user means. * * --root DIR -> that, explicitly * inside a project-> its parent is the root, and we remember which project * inside a session-> the root the session is registered against * otherwise -> the cwd is the root */ export function resolveRoot(options: { rootFlag?: string; cwd: string; sessionEnv?: string | null; registry?: Registry; }): ResolvedRoot { const registry = options.registry ?? {}; const cwd = resolve(options.cwd); if (options.rootFlag) { const root = resolve(options.rootFlag); return { root, session: sessionNameFor(root, registry) }; } if (isProjectDir(cwd)) { const root = dirname(cwd); return { root, session: sessionNameFor(root, registry), project: basename(cwd) }; } if (options.sessionEnv && registry[options.sessionEnv]) { const root = resolve(registry[options.sessionEnv] as string); return { root, session: options.sessionEnv }; } return { root: cwd, session: sessionNameFor(cwd, registry) }; } /** Record that `session` belongs to `root`, so later runs can find it. */ export function rememberRoot(session: string, root: string, env?: NodeJS.ProcessEnv): void { const registry = loadRegistry(env); const resolved = resolve(root); if (registry[session] === resolved) return; registry[session] = resolved; saveRegistry(registry, env); } export function rootExists(root: string): boolean { return existsSync(root); }