1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
|
/**
* 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>.<hash8>` 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:<relPath>`, 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;
}
|