aboutsummaryrefslogtreecommitdiffstats
path: root/cli/src/core/plan.ts
blob: 7c947d2d813ada81bd6cd9b53bc510c492bc62e3 (plain) (blame)
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;
}