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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
|
/**
* The idempotency core.
*
* `sl0ppi up` must be a no-op the second time you run it. Everything else in
* the CLI is plumbing; if this diff is wrong the tool is worse than useless.
* So it is a pure function over data — no filesystem, no subprocesses — and the
* tests hammer it directly.
*/
import { isProjectPurpose, projectPurpose, projectRelPath } from "./purpose.ts";
export interface TabInfo {
/** zellij tab position, which is what `go-to-tab` wants. */
position: number;
name: string;
purpose?: string;
}
export interface PlanInput {
/** Absolute path of the root whose session this is. */
root: string;
/** Whether the root itself is present (see the mount guard below). */
rootExists: boolean;
/** Discovered projects, as paths relative to the root. */
projects: string[];
/** Tabs currently in the session. */
tabs: TabInfo[];
/** Injected so the planner stays pure and testable. */
pathExists: (absPath: string) => boolean;
}
export interface CreateAction {
relPath: string;
purpose: string;
}
export interface Plan {
create: CreateAction[];
close: TabInfo[];
/** Project tabs that already match a discovered project. */
keep: TabInfo[];
/** Tabs sl0ppi does not manage (no `project:` purpose). Never touched. */
foreign: TabInfo[];
}
export function planTabs(input: PlanInput): Plan {
const { root, rootExists, projects, tabs, pathExists } = input;
// Expected state: one tab per discovered project, keyed by purpose.
const expected = new Map<string, string>(); // purpose -> relPath
for (const relPath of projects) expected.set(projectPurpose(relPath), relPath);
const create: CreateAction[] = [];
const close: TabInfo[] = [];
const keep: TabInfo[] = [];
const foreign: TabInfo[] = [];
const seen = new Set<string>();
for (const tab of tabs) {
if (!isProjectPurpose(tab.purpose)) {
// Someone else's tab (a scratch tab the user opened by hand). Not ours.
foreign.push(tab);
continue;
}
const purpose = tab.purpose as string;
if (expected.has(purpose)) {
keep.push(tab);
seen.add(purpose);
continue;
}
// Not a project we discovered. Either its directory is gone, or it is a
// project outside the depth-1 scan that was opened explicitly. Only the
// first justifies closing a tab, and closing is destructive, so every
// uncertain case keeps the tab.
if (!rootExists) {
// The ROOT is missing, not the project. That is an unmounted filesystem,
// not twelve deleted projects: closing here would eat the whole session.
keep.push(tab);
continue;
}
const relPath = projectRelPath(purpose);
if (relPath === null) {
// Purpose does not reconstruct (truncated/hashed), so we cannot check
// the directory. Refuse to guess.
keep.push(tab);
continue;
}
if (pathExists(joinPath(root, relPath))) {
// Directory is there; it simply was not discovered (nested, or its
// .sl0ppi/ marker was removed). Leave it alone.
keep.push(tab);
continue;
}
close.push(tab);
}
// Alphabetical, per the agreed MVP ordering.
for (const relPath of [...projects].sort()) {
const purpose = projectPurpose(relPath);
if (!seen.has(purpose)) create.push({ relPath, purpose });
}
return { create, close, keep, foreign };
}
/** Trivial join; avoids pulling node:path into a pure module. */
function joinPath(root: string, rel: string): string {
return root.endsWith("/") ? `${root}${rel}` : `${root}/${rel}`;
}
/** True when applying this plan would change nothing. */
export function isNoOp(plan: Plan): boolean {
return plan.create.length === 0 && plan.close.length === 0;
}
|