chamba 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/LICENSE +53 -14
  2. package/README.md +34 -8
  3. package/dist/commands/advanced.js +7 -278
  4. package/dist/commands/dev.js +11 -612
  5. package/dist/commands/doctor.js +1 -29
  6. package/dist/commands/menu.js +1 -80
  7. package/dist/commands/onboard.js +6 -230
  8. package/dist/commands/settings.js +15 -349
  9. package/dist/lib/agent-commands.js +2 -0
  10. package/dist/lib/agent-context.js +6 -210
  11. package/dist/lib/browser.js +1 -40
  12. package/dist/lib/chamba-yaml.js +10 -191
  13. package/dist/lib/constants.js +1 -125
  14. package/dist/lib/dockerfile-builder.js +41 -261
  15. package/dist/lib/env.js +2 -78
  16. package/dist/lib/git-env.js +1 -21
  17. package/dist/lib/global-config.js +4 -66
  18. package/dist/lib/pnpm-store.js +1 -19
  19. package/dist/lib/ports.js +1 -210
  20. package/dist/lib/safe-rm.js +1 -36
  21. package/dist/lib/sessions.js +2 -34
  22. package/dist/lib/shadows.js +1 -176
  23. package/dist/lib/skills.js +2 -0
  24. package/dist/lib/templates.js +1 -0
  25. package/dist/lib/webterm.js +1 -305
  26. package/dist/lib/workspace-identity.js +4 -260
  27. package/package.json +4 -4
  28. package/templates/Dockerfile +15 -22
  29. package/templates/claude-statusline.sh +5 -7
  30. package/templates/context/baseline.md +4 -2
  31. package/templates/context/web-pane-craft.md +1 -1
  32. package/templates/pane-apps/client/assets/api-BBDf_rzz.js +12 -0
  33. package/templates/pane-apps/client/assets/api-DNLbSBpK.css +1 -0
  34. package/templates/pane-apps/client/assets/bee-B7t97l5B.webp +0 -0
  35. package/templates/pane-apps/client/assets/buzz-CUUsbnXH.mp3 +0 -0
  36. package/templates/pane-apps/client/assets/c-BNa-DeUl.json +3552 -0
  37. package/templates/pane-apps/client/assets/cpp-B-T4NRoH.json +20239 -0
  38. package/templates/pane-apps/client/assets/csharp-BbxHChhA.json +6010 -0
  39. package/templates/pane-apps/client/assets/css-CWCxuwmz.json +1860 -0
  40. package/templates/pane-apps/client/assets/diff-B-2N89g8.json +154 -0
  41. package/templates/pane-apps/client/assets/docker-DWTvYW26.json +103 -0
  42. package/templates/pane-apps/client/assets/files-BkmyervY.js +1 -0
  43. package/templates/pane-apps/client/assets/go-B2hyYeNg.json +3156 -0
  44. package/templates/pane-apps/client/assets/graphql-Z6Id2aNO.json +1292 -0
  45. package/templates/pane-apps/client/assets/home-3SeGzySM.css +1 -0
  46. package/templates/pane-apps/client/assets/home-Cu9xOd2i.js +1 -0
  47. package/templates/pane-apps/client/assets/html-BtzvlIfu.json +2627 -0
  48. package/templates/pane-apps/client/assets/ini-Bn3Twap1.json +108 -0
  49. package/templates/pane-apps/client/assets/java-C7t2dwLO.json +1885 -0
  50. package/templates/pane-apps/client/assets/javascript-DuC2v0C0.json +5996 -0
  51. package/templates/pane-apps/client/assets/json-CwdkWeV5.json +206 -0
  52. package/templates/pane-apps/client/assets/jsonc-MYWDbTU3.json +206 -0
  53. package/templates/pane-apps/client/assets/jsx-Cn8EZuIC.json +5996 -0
  54. package/templates/pane-apps/client/assets/kotlin-CFNOz1gw.json +580 -0
  55. package/templates/pane-apps/client/assets/make-BVnlFCcl.json +629 -0
  56. package/templates/pane-apps/client/assets/markdown-B4Xk2WM_.json +3263 -0
  57. package/templates/pane-apps/client/assets/php-CAQzQmmI.json +4188 -0
  58. package/templates/pane-apps/client/assets/pop-D5ZANBC3.mp3 +0 -0
  59. package/templates/pane-apps/client/assets/python-CYQee8m4.json +4174 -0
  60. package/templates/pane-apps/client/assets/reviews-DMrGB-kG.js +153 -0
  61. package/templates/pane-apps/client/assets/reviews-nK_yRav9.css +1 -0
  62. package/templates/pane-apps/client/assets/ruby-D7R58tus.json +3114 -0
  63. package/templates/pane-apps/client/assets/rust-BFBbFiei.json +1103 -0
  64. package/templates/pane-apps/client/assets/scss-TQRhBkPG.json +1874 -0
  65. package/templates/pane-apps/client/assets/shellscript-DieAkC4W.json +2375 -0
  66. package/templates/pane-apps/client/assets/specs-Cabb15F0.js +13 -0
  67. package/templates/pane-apps/client/assets/sql-Bxd030LV.json +634 -0
  68. package/templates/pane-apps/client/assets/toml-ChrKGzsV.json +430 -0
  69. package/templates/pane-apps/client/assets/tsx-D2e90MFw.json +5996 -0
  70. package/templates/pane-apps/client/assets/typescript-DonXQ_n1.json +5747 -0
  71. package/templates/pane-apps/client/assets/useNow-BHFKFraS.js +1 -0
  72. package/templates/pane-apps/client/assets/xml-B17r2RXF.json +382 -0
  73. package/templates/pane-apps/client/assets/yaml-CKn4h1zy.json +627 -0
  74. package/templates/pane-apps/client/home/index.html +16 -0
  75. package/templates/pane-apps/client/reviews/index.html +17 -0
  76. package/templates/pane-apps/client/specs/index.html +4 -2
  77. package/templates/pane-apps/server/home.mjs +12 -0
  78. package/templates/pane-apps/server/reviews.mjs +21 -0
  79. package/templates/pane-apps/server/specs.mjs +5 -1588
  80. package/templates/skills/chamba-statusline/SKILL.md +7 -19
  81. package/templates/skills/dx-review/SKILL.md +136 -0
  82. package/templates/skills/dx-review/references/acts.md +233 -0
  83. package/templates/skills/dx-spec/SKILL.md +230 -30
  84. package/templates/skills/dx-spec/references/principles-template.md +2 -0
  85. package/templates/skills/dx-spec/references/review-guide.md +6 -52
  86. package/templates/skills/dx-spec/references/spec-guide.md +25 -5
  87. package/templates/skills/dx-spec/references/stages.md +149 -60
  88. package/templates/skills/dx-spec-execute/SKILL.md +217 -38
  89. package/templates/startup.mjs +22 -16
  90. package/templates/tool-helper.sh +168 -0
  91. package/templates/webterm/README.md +82 -27
  92. package/templates/webterm/color.js +61 -0
  93. package/templates/webterm/config.js +55 -4
  94. package/templates/webterm/context/claude.md +0 -1
  95. package/templates/webterm/public/app/alerts.js +95 -14
  96. package/templates/webterm/public/app/anchored.js +81 -0
  97. package/templates/webterm/public/app/composer.js +37 -3
  98. package/templates/webterm/public/app/connection.js +23 -0
  99. package/templates/webterm/public/app/dom.js +3 -0
  100. package/templates/webterm/public/app/frames.js +52 -18
  101. package/templates/webterm/public/app/home-host.js +16 -0
  102. package/templates/webterm/public/app/main.js +24 -8
  103. package/templates/webterm/public/app/new-session.js +13 -41
  104. package/templates/webterm/public/app/palette.js +58 -0
  105. package/templates/webterm/public/app/pane-shape.js +169 -0
  106. package/templates/webterm/public/app/pane-shell.js +240 -135
  107. package/templates/webterm/public/app/pane.js +39 -24
  108. package/templates/webterm/public/app/reviews-host.js +19 -0
  109. package/templates/webterm/public/app/specs-host.js +12 -218
  110. package/templates/webterm/public/app/state.js +5 -0
  111. package/templates/webterm/public/app/tabs.js +6 -2
  112. package/templates/webterm/public/app/theme.js +75 -27
  113. package/templates/webterm/public/app/tool-host.js +318 -0
  114. package/templates/webterm/public/app/workspace-color.js +192 -0
  115. package/templates/webterm/public/index.html +22 -4
  116. package/templates/webterm/public/styles.css +195 -21
  117. package/templates/webterm/server.js +198 -150
  118. package/templates/webterm/tool-document.js +3 -2
  119. package/templates/webterm/tools/commands.js +61 -0
  120. package/templates/webterm/tools/home.js +58 -0
  121. package/templates/webterm/tools/index.js +26 -0
  122. package/templates/webterm/tools/mount.js +337 -0
  123. package/templates/webterm/tools/paths.js +34 -0
  124. package/templates/webterm/tools/reviews.js +137 -0
  125. package/templates/webterm/tools/specs.js +154 -0
  126. package/templates/webterm/typed-line.js +70 -7
  127. package/templates/context/context-usage.md +0 -1
  128. package/templates/context-usage.sh +0 -266
  129. package/templates/pane-apps/client/assets/specs-B1970L17.css +0 -1
  130. package/templates/pane-apps/client/assets/specs-cEee_SPn.js +0 -23
  131. package/templates/skills/dx-spec-config/SKILL.md +0 -313
  132. package/templates/skills/dx-spec-config/references/principles-template.md +0 -12
  133. package/templates/specs.sh +0 -106
  134. package/templates/webterm/specs.js +0 -358
@@ -19,18 +19,25 @@
19
19
  //
20
20
  // state.js what the server last told this window, and the URL's key
21
21
  // dom.js the elements index.html ships with
22
- // theme.js the palette, this workspace's colour, and the session colours
22
+ // palette.js the eleven workspace colours, the six session slots, and the rules over them
23
+ // theme.js the colour this workspace is wearing, and the session colours that follow from it
23
24
  // note.js the status pill over the terminal
24
25
  // terminal.js the terminal, its size, and the keystrokes that go straight to the PTY
25
26
  // tabs.js the session bar, its two animations, renaming, and reordering
27
+ // anchored.js a panel hung off the body under the button that opened it, and how it closes
26
28
  // new-session.js the panel behind the chevron: which agent, and which directory
29
+ // workspace-color.js the colour this workspace wears: the button in the bar, and the panel of eleven
27
30
  // alerts.js the browser tab (title and drawn favicon), and the chime
28
31
  // cards.js the overlay cards
29
32
  // connection.js the socket, the reconnect loop, and the curtain
30
33
  // frames.js what to do with each frame the server sends
31
34
  // pane-shell.js the pane itself: the tool tabs, the three widths, the drag and the spine
35
+ // pane-shape.js the shape a pane has beside one session, and the store that keeps one for each
32
36
  // pane.js the Pages tool: the chips bar and the page
33
- // specs-host.js the Specs tool: its frame, and the credentials the shell hands into it
37
+ // tool-host.js a tool's frame, and the credentials the shell hands into it
38
+ // home-host.js the Home tool: which tool it is, and its panel
39
+ // specs-host.js the Specs tool, the same way
40
+ // reviews-host.js the Code Reviews tool, the same way again
34
41
  // pane-frame.js a published page in a sandboxed frame, and the answer that comes back out of it
35
42
  // pane-arrival.js which page an incoming list opens by itself, and when none of them does
36
43
  // status-strip.js the attached session's model, context, quota meters and version, above the composer
@@ -45,9 +52,9 @@
45
52
  // router talks to every feature and every feature answers back - so a module that ran another module's code at
46
53
  // load time would be reaching into something half-built. Everything with an order to it happens here.
47
54
  //
48
- // Reading at load time follows from the same thing: a module may read from one that imports nothing itself
49
- // (dom.js, state.js, theme.js), since a leaf is always finished by the time anything else runs, and never from
50
- // one inside the cycle, whose values may not exist yet.
55
+ // Reading at load time follows from the same thing: a module may read from one that sits outside the cycle
56
+ // (dom.js, state.js, theme.js, and palette.js under it), since such a module is always finished by the time
57
+ // anything else runs, and never from one inside the cycle, whose values may not exist yet.
51
58
  //
52
59
  // Every module is imported here, including the ones this file has nothing to call. Several exist for what they
53
60
  // do when they load - the microphone button, the chevron panel's Escape key, the workspace colour, the draft
@@ -58,22 +65,29 @@
58
65
  import { refreshBrowserTab } from "./alerts.js";
59
66
  import { installClipboard } from "./clipboard.js";
60
67
  import { refreshComposer } from "./composer.js";
61
- import { connect } from "./connection.js";
68
+ import { connect, LAST_SID_KEY } from "./connection.js";
69
+ import "./anchored.js";
62
70
  import "./cards.js";
63
71
  import "./dictation.js";
64
72
  import "./dom.js";
73
+ import "./palette.js";
65
74
  import "./drafts.js";
66
75
  import "./frames.js";
76
+ import "./home-host.js";
67
77
  import "./new-session.js";
68
78
  import "./note.js";
69
79
  import "./pane.js";
70
80
  import "./pane-arrival.js";
81
+ import "./pane-shape.js";
71
82
  import "./pane-frame.js";
83
+ import "./reviews-host.js";
72
84
  import "./specs-host.js";
85
+ import "./tool-host.js";
73
86
  import "./state.js";
74
87
  import "./status-strip.js";
75
88
  import "./strip-format.js";
76
89
  import "./theme.js";
90
+ import "./workspace-color.js";
77
91
  import { mountPane } from "./pane-shell.js";
78
92
  import { renderBar } from "./tabs.js";
79
93
  import { mountTerminal, term } from "./terminal.js";
@@ -83,8 +97,10 @@ import { mountTerminal, term } from "./terminal.js";
83
97
  mountTerminal();
84
98
  installClipboard();
85
99
  refreshComposer();
86
- // After every tool has registered, which is what makes the pane something to draw.
87
- mountPane();
100
+ // After every tool has registered, which is what makes the pane something to draw. In the shape of the
101
+ // session this window was last on, which is the session the server is about to be asked for: the pane is drawn
102
+ // once, in what it will be, rather than at the defaults and then again a frame later.
103
+ mountPane(sessionStorage.getItem(LAST_SID_KEY));
88
104
  renderBar();
89
105
  // Before the first frame arrives, so the icon carries this workspace's frame from the moment the page loads.
90
106
  refreshBrowserTab();
@@ -16,6 +16,7 @@
16
16
  // Nothing typed here is trusted: the server resolves whatever path arrives and refuses what is not a directory in the
17
17
  // workspace, and it refuses an agent it does not run.
18
18
 
19
+ import { createAnchoredPanel } from "./anchored.js";
19
20
  import { sendFrame } from "./connection.js";
20
21
  import { noteMsg } from "./note.js";
21
22
  import { agents, defaultAgent, defaultCwd, KEY_QUERY } from "./state.js";
@@ -23,36 +24,17 @@ import { focusTerminal } from "./terminal.js";
23
24
 
24
25
  const DIR_LIST_ID = "dirlist";
25
26
 
26
- let newPopEl = null;
27
+ const panel = createAnchoredPanel("newtab-more");
27
28
 
28
- function closeNewPop() {
29
- if (!newPopEl) return;
30
- newPopEl.remove();
31
- newPopEl = null;
32
- document.removeEventListener("pointerdown", onNewPopOutside, true);
33
- }
34
-
35
- // Put an open panel back under its button after the bar was rebuilt. A frame arrives whenever anything in the
36
- // container moves, and a panel that closed itself every time one did would be unusable; a button that is gone
37
- // (the session limit) takes it with it. Clamped to the window, since the bar can be scrolled far to the right.
29
+ // Put an open panel back under its button after the bar was rebuilt. Called from renderBar().
38
30
  export function repositionNewPop() {
39
- if (!newPopEl) return;
40
- const anchor = document.getElementById("newtab-more");
41
- if (!anchor) {
42
- closeNewPop();
43
- return;
44
- }
45
- const box = anchor.getBoundingClientRect();
46
- const width = newPopEl.offsetWidth;
47
- const left = Math.min(box.right - width, window.innerWidth - width - 8);
48
- newPopEl.style.top = `${Math.round(box.bottom + 6)}px`;
49
- newPopEl.style.left = `${Math.round(Math.max(8, left))}px`;
31
+ panel.reposition();
50
32
  }
51
33
 
52
- function onNewPopOutside(event) {
53
- // The button itself is left alone: its own click handler toggles, and closing here first would reopen it.
54
- if (newPopEl?.contains(event.target) || event.target.closest?.("#newtab-more")) return;
55
- closeNewPop();
34
+ // Called when the socket drops. The panel hangs off the body, where the bar's offline rules cannot reach it,
35
+ // and nothing it offers can be answered with the socket down.
36
+ export function closeNewPop() {
37
+ panel.close();
56
38
  }
57
39
 
58
40
  // Fill the picker's suggestions from the container. Fetched when the panel opens rather than kept around, so
@@ -118,8 +100,8 @@ function popLabel(text) {
118
100
  }
119
101
 
120
102
  export function toggleNewPop() {
121
- if (newPopEl) {
122
- closeNewPop();
103
+ if (panel.isOpen()) {
104
+ panel.close();
123
105
  return;
124
106
  }
125
107
  const state = { agent: defaultAgent };
@@ -144,7 +126,7 @@ export function toggleNewPop() {
144
126
  pop.append(popLabel("Directory"), field, list);
145
127
 
146
128
  const start = () => {
147
- closeNewPop();
129
+ panel.close();
148
130
  sendFrame({ t: "new", cwd: field.value.trim(), agent: state.agent });
149
131
  };
150
132
  const row = document.createElement("div");
@@ -154,7 +136,7 @@ export function toggleNewPop() {
154
136
  cancel.className = "btn";
155
137
  cancel.textContent = "Cancel";
156
138
  cancel.addEventListener("click", () => {
157
- closeNewPop();
139
+ panel.close();
158
140
  focusTerminal();
159
141
  });
160
142
  const go = document.createElement("button");
@@ -171,18 +153,8 @@ export function toggleNewPop() {
171
153
  start();
172
154
  });
173
155
 
174
- document.body.append(pop);
175
- newPopEl = pop;
176
- repositionNewPop();
156
+ panel.open(pop);
177
157
  field.focus();
178
158
  field.select();
179
159
  fillDirList(list);
180
- // Capture, so a click on a tab closes this before that tab switches session.
181
- document.addEventListener("pointerdown", onNewPopOutside, true);
182
160
  }
183
-
184
- document.addEventListener("keydown", (event) => {
185
- if (event.key !== "Escape" || !newPopEl) return;
186
- closeNewPop();
187
- focusTerminal();
188
- });
@@ -0,0 +1,58 @@
1
+ // palette.js - the colours a workspace can wear, the slots a session rotates through, and the rules between them.
2
+ //
3
+ // Data and arithmetic only. Nothing here reads a browser global or does anything at load, which is what lets
4
+ // the tests import this file and check the rules by running them rather than by matching the source text.
5
+ // theme.js is what puts any of it on the page.
6
+ //
7
+ // The ids are the stored contract: a workspace remembers "azure" and never a hex, so a later version can move
8
+ // a shade without stranding a workspace on a value nobody chose. An id this version does not know means
9
+ // nothing is stored, and the workspace falls back to the colour the port gives it.
10
+
11
+ // Cyberpunk neons on a near-black bar, in hue order, and deliberately free of yellow, orange and red - those
12
+ // read as a warning here, and the bar has enough to say without one. Two rules hold over this list, and both
13
+ // are checked in tests/webterm-sessions.test.ts: every colour clears 3:1 against the interface's grounds, and
14
+ // no two are closer than 17.2 in CIE76, which is lime beside green, the closest pair this table already holds.
15
+ export const PALETTE = [
16
+ { id: "lime", hex: "#7ae62f" },
17
+ { id: "green", hex: "#3fe54f" },
18
+ { id: "spring-green", hex: "#2fe58a" },
19
+ { id: "turquoise", hex: "#1fe0cf" },
20
+ { id: "azure", hex: "#29b6ff" },
21
+ { id: "blue", hex: "#5c86ff" },
22
+ { id: "indigo", hex: "#305efe" },
23
+ { id: "neon-violet", hex: "#b45cff" },
24
+ { id: "magenta", hex: "#f24bff" },
25
+ { id: "orchid", hex: "#ff4ecb" },
26
+ { id: "rose", hex: "#ff2e88" },
27
+ ];
28
+
29
+ // The six slots a session dot rotates through, written out in the order the interface has always had them.
30
+ // Restricting the hue-ordered list above to these six gives a different order - azure and magenta change
31
+ // places - so the order is a written fact here rather than a side effect of the table. Deriving it would move
32
+ // the automatic colour of every workspace whose port is congruent to 2 or 4 modulo 6, and shift the dot order
33
+ // of every session already open.
34
+ export const SESSION_IDS = ["spring-green", "turquoise", "magenta", "neon-violet", "azure", "rose"];
35
+
36
+ /** The hex for one id, or null when this version does not know the id. */
37
+ export function hexFor(id) {
38
+ return PALETTE.find((colour) => colour.id === id)?.hex ?? null;
39
+ }
40
+
41
+ // The colour a workspace wears before anyone picks one, worked out over the six in their order and never over
42
+ // the eleven. Ports are handed out lowest-free-first per workspace, so neighbours land on different colours.
43
+ // The eleven are the set the user picks from, and they take no part in this: any other reading would move the
44
+ // colour of every workspace where nobody ever chose one.
45
+ export function automaticId(seed) {
46
+ return SESSION_IDS[seed % SESSION_IDS.length];
47
+ }
48
+
49
+ // Every session slot except the one the workspace is wearing, so "never the workspace colour" is a property of
50
+ // the list the tabs index into rather than a rule someone has to remember. The pool is always five long: when
51
+ // the workspace wears a colour from outside the six, the slot the port would have chosen is the one held back,
52
+ // which leaves the registry's PALETTE_SIZE untouched and the dots where they were.
53
+ //
54
+ // `autoId` is the id automaticId() gave this window, and is always one of the six.
55
+ export function sessionColors(workspaceId, autoId) {
56
+ const taken = SESSION_IDS.includes(workspaceId) ? workspaceId : autoId;
57
+ return SESSION_IDS.filter((id) => id !== taken).map(hexFor);
58
+ }
@@ -0,0 +1,169 @@
1
+ // pane-shape.js - what a pane's shape is, the rules that decide one, and the store that keeps one per session.
2
+ //
3
+ // The pane's own DOM-free module, so every rule here can be run rather than read as source: a browser entry
4
+ // written by an older version, a spoiled field, a session that ended, and the arithmetic of a drag. Nothing
5
+ // here holds an element or reaches another module, and each function is given whatever it works on, which is
6
+ // what lets a test hand it a plain object and a window width of its own choosing.
7
+ //
8
+ // The shape itself is one session's answer to how the pane looks beside it: how wide, whether it is put away,
9
+ // whether it has the whole row, which tool is open, how big a page is drawn, and which palette the
10
+ // reading is in. Nothing in it is about the work, so a shape that is lost costs a drag of the divider.
11
+
12
+ // One entry for the whole interface, holding a shape for each session. Named the way the interface names its
13
+ // own: webterm-color and webterm-drafts are its neighbours.
14
+ export const PANE_KEY = "webterm-pane";
15
+
16
+ // The tools there are to open, which a shape holds one of. Their ids and nothing else about them: what a tool
17
+ // is made of is the pane's business, and all this module asks is whether a stored id is one of them.
18
+ export const HOME = "home";
19
+ export const PAGES = "pages";
20
+ export const SPECS = "specs";
21
+ export const REVIEWS = "reviews";
22
+ const SHAPE_TOOLS = [HOME, PAGES, SPECS, REVIEWS];
23
+ // The tool a session opens on. Home, because it is the reading of the repository a session starts in, and
24
+ // because a stored shape naming another tool keeps that tool: this is what a session with no shape gets.
25
+ const DEFAULT_TOOL = HOME;
26
+
27
+ // Narrower than this and the pane is not a pane any more, so it becomes the spine instead. The mock's
28
+ // threshold, and the width the spine itself takes.
29
+ export const COLLAPSE_AT = 150;
30
+ // What the pane opens at, in a fraction of the window. Half is the default width itself rather than a pixel
31
+ // count, so a pane nobody has dragged stays at half through a window resize.
32
+ export const DEFAULT_FRACTION = 0.5;
33
+ // The most of the window a drag may give the pane.
34
+ const MAX_FRACTION = 0.7;
35
+ // How much further than that a drag has to go before it means the far end. Past the clamp the pane stops
36
+ // following the pointer, so without this the only way to the full width is the button, and a drag that keeps
37
+ // going reads as a request nothing answers. Wide enough that reaching the clamp is not the same as passing
38
+ // it, and the way back is the drag itself: anything short of this is a width again, which is the state the
39
+ // pane returns to when the pointer comes back.
40
+ const FULL_AT = 90;
41
+ // The least of the window the pane may come back into. Reopening is not the same as resizing: whatever width
42
+ // it was put away at, a pane the user has just asked for has to be wide enough to read a page in.
43
+ const MIN_OPEN_FRACTION = 1 / 3;
44
+
45
+ /** What a session nobody has shaped opens on. */
46
+ export function defaultShape() {
47
+ return { width: null, collapsed: false, full: false, tool: DEFAULT_TOOL, size: null, theme: null };
48
+ }
49
+
50
+ // --- How wide ----------------------------------------------------------------------------------------------------------------------------
51
+
52
+ /**
53
+ * Where a drag of the divider leaves the pane: a width, put away, or the whole row.
54
+ *
55
+ * `wanted` is what the pointer asks for, and `before` is the width the pane had when the drag started. Both
56
+ * ends of the drag are the same rule read at a different threshold: past the near one the pane is put away
57
+ * and hands its old width back, rather than keeping the sliver the pointer passed through on the way there;
58
+ * past the far one it takes the row; and anywhere between them it is the width under the pointer, clamped.
59
+ *
60
+ * The clamp is what the pane is left holding, so coming back from the far end lands there rather than
61
+ * wherever the pointer had run to.
62
+ */
63
+ export function draggedTo(wanted, windowWidth, before) {
64
+ const ceiling = windowWidth * MAX_FRACTION;
65
+ const full = wanted > ceiling + FULL_AT;
66
+ if (wanted < COLLAPSE_AT) return { width: before, collapsed: true, full };
67
+ return { width: Math.min(wanted, ceiling), collapsed: false, full };
68
+ }
69
+
70
+ /**
71
+ * The width a pane that is being brought back has to open into, which is the width it was put away at unless
72
+ * that would be a sliver.
73
+ *
74
+ * A pane dragged shut, or one a narrower window left narrow, has to come back as something a page fits in.
75
+ * A pane with no width of its own keeps none: the default fraction is wide enough by definition.
76
+ */
77
+ export function reopenedFrom(shape, windowWidth) {
78
+ if (shape.width === null) return null;
79
+ return Math.max(shape.width, windowWidth * MIN_OPEN_FRACTION);
80
+ }
81
+
82
+ /**
83
+ * Whether the pane is at the width it opens at: no width of its own, no far end, and not the spine.
84
+ *
85
+ * What the half-width button is lit by, and therefore what a drag takes away and that button gives back.
86
+ */
87
+ export function isDefaultWidth(shape) {
88
+ return shape.width === null && !shape.full && !shape.collapsed;
89
+ }
90
+
91
+ // --- The store ---------------------------------------------------------------------------------------------------------------------------
92
+
93
+ /** One stored shape, field by field: anything missing or malformed falls back to that field's default. */
94
+ export function adoptShape(stored) {
95
+ const shape = defaultShape();
96
+ if (stored === null || typeof stored !== "object") return shape;
97
+ if (typeof stored.width === "number" && stored.width >= COLLAPSE_AT) shape.width = stored.width;
98
+ if (typeof stored.collapsed === "boolean") shape.collapsed = stored.collapsed;
99
+ if (typeof stored.full === "boolean") shape.full = stored.full;
100
+ if (SHAPE_TOOLS.includes(stored.tool)) shape.tool = stored.tool;
101
+ // The multiplier is stored rather than its position, so a session that was left at a size this version no
102
+ // longer offers falls back to the default instead of landing between two steps.
103
+ if (typeof stored.size === "number" && Number.isFinite(stored.size)) shape.size = stored.size;
104
+ if (stored.theme === "dark" || stored.theme === "light") shape.theme = stored.theme;
105
+ return shape;
106
+ }
107
+
108
+ /**
109
+ * Every shape in the store, as it stands right now.
110
+ *
111
+ * An entry from the version that kept one shape for the whole window carries no `sessions`, and there is no
112
+ * session it could be meant for, so it is dropped rather than handed to the first session that asks. So is
113
+ * anything unreadable: a shape nobody can parse means the pane opens on its defaults, and never less.
114
+ */
115
+ export function readShapes(storage) {
116
+ const shapes = new Map();
117
+ let held = null;
118
+ try {
119
+ held = JSON.parse(storage.getItem(PANE_KEY) ?? "{}")?.sessions ?? null;
120
+ } catch {
121
+ return shapes;
122
+ }
123
+ if (held === null || typeof held !== "object" || Array.isArray(held)) return shapes;
124
+ for (const [sid, shape] of Object.entries(held)) {
125
+ if (sid !== "") shapes.set(sid, adoptShape(shape));
126
+ }
127
+ return shapes;
128
+ }
129
+
130
+ /** Write the whole store. Storage that refuses only means the pane keeps its shape for this page's lifetime. */
131
+ export function writeShapes(storage, shapes) {
132
+ try {
133
+ storage.setItem(PANE_KEY, JSON.stringify({ sessions: Object.fromEntries(shapes) }));
134
+ } catch {
135
+ // Nothing to do about a disabled or full store, and nothing worth saying to the user about it.
136
+ }
137
+ }
138
+
139
+ /**
140
+ * One session's shape into the store, and every other session's left as it stands.
141
+ *
142
+ * Read, merge, write, because the store is shared by every window of one origin. A window that wrote its own
143
+ * whole map back would hand another session's pane to whatever that window last saw of it, which is how two
144
+ * windows quietly undo each other.
145
+ */
146
+ export function mergeShape(storage, sid, shape) {
147
+ if (!sid) return;
148
+ const shapes = readShapes(storage);
149
+ shapes.set(sid, shape);
150
+ writeShapes(storage, shapes);
151
+ }
152
+
153
+ /**
154
+ * Drop the shape of every session that is no longer live, and say whether anything went.
155
+ *
156
+ * The store outlives the page, so a shape nobody drops here is kept for as long as the browser holds the
157
+ * origin. Called with the session list the server sends, the way the drafts and the composer history are.
158
+ */
159
+ export function pruneShapes(storage, live) {
160
+ const shapes = readShapes(storage);
161
+ let removed = false;
162
+ for (const sid of [...shapes.keys()]) {
163
+ if (live.has(sid)) continue;
164
+ shapes.delete(sid);
165
+ removed = true;
166
+ }
167
+ if (removed) writeShapes(storage, shapes);
168
+ return removed;
169
+ }