/** * Tab identity. * * A project tab is keyed by its zellij tab `purpose`, because that is the only * field the fork guarantees is (a) queryable via `list-tabs --json`, (b) not * overridable in-band by whatever a pane is displaying, and (c) restored * through the layout after a session is resurrected. Tab *names* are UI strings * a human renames; they are not identity. * * The purpose carries the project's path RELATIVE TO THE ROOT, because * `list-panes --json` exposes no `cwd` (verified against a live session), so * there is no other way to get from a tab back to a directory on disk. Slashes * survive the fork's sanitiser, so nested paths work. * * The fork's sanitiser: keep [A-Za-z0-9_.:/-], then truncate to 64 chars. * See sanitize_pane_purpose() in zellij-utils/src/input/layout.rs. */ import { createHash } from "node:crypto"; /** MAX_PANE_PURPOSE in the fork. */ export const PURPOSE_MAX = 64; export const PROJECT_PREFIX = "project:"; /** Everything the fork's sanitiser strips. */ const DISALLOWED = /[^A-Za-z0-9_.:/-]/g; /** Reproduces sanitize_pane_purpose(): filter, then truncate. */ export function sanitizePurpose(raw: string): string { return raw.replace(DISALLOWED, "").slice(0, PURPOSE_MAX); } function hash8(value: string): string { return createHash("sha256").update(value).digest("hex").slice(0, 8); } /** * Marks a purpose whose path was lossy and must never be reconstructed. * * A leading dot is safe as a sentinel because discovery skips dot-entries, so * no real relative path we encode can begin with one. * * This has to be *structural*. The first implementation just re-encoded the * candidate and compared, which silently failed: `project:<47 a's>.` is * itself 64 chars of legal characters, so it re-encodes to itself, the check * passed, and a bogus path was "reconstructed" — which would have closed a * live tab. Caught by `refuses to reconstruct a truncated/hashed purpose`. */ const LOSSY_MARK = "."; /** * The purpose for a project at `relPath` (relative to its root). * * Normally this is simply `project:`, which round-trips back to a * path. When the path would not survive sanitising or does not fit, we fall * back to a marked, truncated form plus a hash: still *unique*, deliberately * not *reconstructable*, so `projectRelPath()` refuses it and we never * auto-close a tab we cannot verify. */ export function projectPurpose(relPath: string): string { const cleaned = sanitizePurpose(relPath); const exact = cleaned === relPath && cleaned.length > 0 && !cleaned.startsWith(LOSSY_MARK) && PROJECT_PREFIX.length + cleaned.length <= PURPOSE_MAX; if (exact) return PROJECT_PREFIX + cleaned; // sentinel(1) + head + "."(1) + hash8(8) const budget = PURPOSE_MAX - PROJECT_PREFIX.length - 10; const head = cleaned.slice(0, budget).replace(/[./-]+$/, "") || "project"; return `${PROJECT_PREFIX}${LOSSY_MARK}${head}.${hash8(relPath)}`; } export function isProjectPurpose(purpose: string | undefined | null): boolean { return typeof purpose === "string" && purpose.startsWith(PROJECT_PREFIX); } /** * The relative path a purpose encodes, or null when it cannot be reconstructed * exactly. * * Null is not a failure, it is a refusal. A lossy purpose is marked at encode * time and rejected here, so callers cannot mistake `project:.head.a1b2c3d4` * for a directory literally named `head.a1b2c3d4` and close a tab over it. The * re-encode comparison stays as a second line of defence. */ export function projectRelPath(purpose: string): string | null { if (!isProjectPurpose(purpose)) return null; const candidate = purpose.slice(PROJECT_PREFIX.length); if (candidate.length === 0) return null; if (candidate.startsWith(LOSSY_MARK)) return null; return projectPurpose(candidate) === purpose ? candidate : null; }