chamba 0.6.1 → 0.7.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.
- package/README.md +13 -6
- package/dist/lib/agent-context.js +33 -7
- package/dist/lib/dockerfile-builder.js +2 -1
- package/dist/lib/safe-rm.js +13 -3
- package/package.json +3 -3
- package/templates/Dockerfile +20 -1
- package/templates/pane-apps/client/assets/specs-B1970L17.css +1 -0
- package/templates/pane-apps/client/assets/specs-cEee_SPn.js +23 -0
- package/templates/pane-apps/client/specs/index.html +13 -0
- package/templates/pane-apps/server/specs.mjs +1588 -0
- package/templates/skills/dx-spec/SKILL.md +365 -0
- package/templates/skills/dx-spec/references/imagination-guide.md +140 -0
- package/templates/skills/dx-spec/references/review-guide.md +173 -0
- package/templates/skills/dx-spec/references/spec-guide.md +125 -0
- package/templates/skills/dx-spec/references/stages.md +399 -0
- package/templates/skills/dx-spec-config/SKILL.md +313 -0
- package/templates/skills/dx-spec-config/references/principles-template.md +12 -0
- package/templates/skills/dx-spec-execute/SKILL.md +324 -0
- package/templates/specs.sh +106 -0
- package/templates/webterm/README.md +42 -6
- package/templates/webterm/config.js +43 -0
- package/templates/webterm/public/app/composer.js +4 -1
- package/templates/webterm/public/app/dom.js +13 -5
- package/templates/webterm/public/app/frames.js +7 -0
- package/templates/webterm/public/app/main.js +7 -1
- package/templates/webterm/public/app/pane-shell.js +315 -0
- package/templates/webterm/public/app/pane.js +58 -183
- package/templates/webterm/public/app/specs-host.js +222 -0
- package/templates/webterm/public/app/terminal.js +8 -0
- package/templates/webterm/public/index.html +51 -27
- package/templates/webterm/public/styles.css +144 -30
- package/templates/webterm/server.js +273 -0
- package/templates/webterm/specs.js +358 -0
- package/templates/webterm/tool-document.js +67 -0
- package/templates/webterm/typed-line.js +85 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// pane-shell.js - the pane itself: which tool is open in it, and how wide it is.
|
|
2
|
+
//
|
|
3
|
+
// The pane is furniture. What sits in it are tools - Pages (pane.js), Specs (specs-host.js) - and the line
|
|
4
|
+
// between them is the whole point of this module: a tool draws inside its own panel and nothing else, while
|
|
5
|
+
// the tab bar that says which tool is open, and the controls that say how wide the pane is, are drawn out
|
|
6
|
+
// here. A tool can therefore never move the pane, and never lie about which tool the user is looking at.
|
|
7
|
+
//
|
|
8
|
+
// Width has three states, and only one of them is a width. Between the two ends the divider drags anything,
|
|
9
|
+
// clamped as it always was. Past the near end the pane is put away and becomes the spine. Past the far end it
|
|
10
|
+
// takes the whole row and the terminal is squeezed out - which is a state rather than a wider drag, so the
|
|
11
|
+
// drag's own clamps stay where they are. The grip is reachable in every one of them, and dragging it is
|
|
12
|
+
// always the way back.
|
|
13
|
+
//
|
|
14
|
+
// What the pane is belongs to the window - how wide, which tool, how big a page is drawn - and survives a
|
|
15
|
+
// session switch and a reload. What is in a tool belongs to the session, and the tool keeps that itself.
|
|
16
|
+
//
|
|
17
|
+
// This module imports nothing but dom.js, deliberately: the tools import it and register themselves as they
|
|
18
|
+
// load, so it has to be finished before any of them runs.
|
|
19
|
+
|
|
20
|
+
import { grip, pane, panebar, paneCollapse, paneFull, spine, spineBadge, toolPages, toolSpecs, toolTabs } from "./dom.js";
|
|
21
|
+
|
|
22
|
+
// The tools, in the order their tabs are drawn. A tool is a tab, a panel, and a render function it hands over
|
|
23
|
+
// when it loads; the pane knows nothing else about any of them.
|
|
24
|
+
export const PAGES = "pages";
|
|
25
|
+
export const SPECS = "specs";
|
|
26
|
+
const TOOLS = [
|
|
27
|
+
{ id: PAGES, label: "Pages", panel: toolPages },
|
|
28
|
+
{ id: SPECS, label: "Specs", panel: toolSpecs },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// Narrower than this and the pane is not a pane any more, so it becomes the spine instead. The mock's
|
|
32
|
+
// threshold, and the width the spine itself takes.
|
|
33
|
+
const COLLAPSE_AT = 150;
|
|
34
|
+
// What the pane opens at, in a fraction of the window, and the most of the window a drag may give it.
|
|
35
|
+
const DEFAULT_FRACTION = 0.44;
|
|
36
|
+
const MAX_FRACTION = 0.7;
|
|
37
|
+
// The least of the window the pane may come back into. Reopening is not the same as resizing: whatever width
|
|
38
|
+
// it was put away at, a pane you have just asked for has to be wide enough to read a page in.
|
|
39
|
+
const MIN_OPEN_FRACTION = 1 / 3;
|
|
40
|
+
// The window's own pane preferences: how wide, whether it is collapsed, whether it has the whole row, which
|
|
41
|
+
// tool is open, and how big a page is drawn in it. Not the session's - a pane is furniture, and furniture
|
|
42
|
+
// does not move because you looked at another tab.
|
|
43
|
+
const GEOMETRY_KEY = "webterm-pane";
|
|
44
|
+
|
|
45
|
+
let width = null;
|
|
46
|
+
// null means nobody has said. An untouched pane keeps out of the way while the tool it is open on has nothing
|
|
47
|
+
// in it, and is open once that tool has something - which is the first page of a session opening on arrival,
|
|
48
|
+
// as it always did. A click on the spine or on the collapse button is an answer, and from then on it is the
|
|
49
|
+
// only one that counts.
|
|
50
|
+
let collapsed = null;
|
|
51
|
+
// The far end: the pane has the whole row and the terminal is squeezed out.
|
|
52
|
+
let full = false;
|
|
53
|
+
let openTool = PAGES;
|
|
54
|
+
// How big a page is drawn. The pane applies it, so it is stored here with the rest of the furniture, and the
|
|
55
|
+
// Pages tool owns the control and the steps.
|
|
56
|
+
let pageSize = null;
|
|
57
|
+
// What the last render decided, for the two things that only need to know whether the pane is showing.
|
|
58
|
+
let shut = true;
|
|
59
|
+
|
|
60
|
+
// id -> what that tool has to say for itself on its tab: how many things are waiting in it, and whether it
|
|
61
|
+
// has anything at all. A tool nobody has heard from yet is empty and waiting on nothing.
|
|
62
|
+
const EMPTY_MARK = { unread: 0, empty: true };
|
|
63
|
+
const marks = new Map();
|
|
64
|
+
// id -> the tool's own render function, handed over as it loads.
|
|
65
|
+
const renderers = new Map();
|
|
66
|
+
|
|
67
|
+
// --- What the window remembers -----------------------------------------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const stored = JSON.parse(localStorage.getItem(GEOMETRY_KEY) ?? "{}");
|
|
71
|
+
if (typeof stored.width === "number" && stored.width >= COLLAPSE_AT) width = stored.width;
|
|
72
|
+
if (typeof stored.collapsed === "boolean") collapsed = stored.collapsed;
|
|
73
|
+
if (typeof stored.full === "boolean") full = stored.full;
|
|
74
|
+
if (TOOLS.some((tool) => tool.id === stored.tool)) openTool = stored.tool;
|
|
75
|
+
// The multiplier is stored rather than its position, so a window that was left at a size this version no
|
|
76
|
+
// longer offers falls back to the default instead of landing between two steps.
|
|
77
|
+
if (typeof stored.size === "number") pageSize = stored.size;
|
|
78
|
+
} catch {
|
|
79
|
+
// A disabled or full store only means the pane opens at its default width, on its default tool.
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function remember() {
|
|
83
|
+
try {
|
|
84
|
+
localStorage.setItem(GEOMETRY_KEY, JSON.stringify({ width, collapsed, full, tool: openTool, size: pageSize }));
|
|
85
|
+
} catch {
|
|
86
|
+
// The pane still works for this page's lifetime.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** How big a page was last drawn in this window, or null when nobody has said. */
|
|
91
|
+
export function storedPageSize() {
|
|
92
|
+
return pageSize;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The Pages tool's zoom, kept with the rest of the pane's furniture so one key has one writer. */
|
|
96
|
+
export function rememberPageSize(size) {
|
|
97
|
+
pageSize = size;
|
|
98
|
+
remember();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- The tools ---------------------------------------------------------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A tool says how to draw its panel. Called as the tool's module loads, which is why this module imports
|
|
105
|
+
* nothing that could still be half-built at that point.
|
|
106
|
+
*/
|
|
107
|
+
export function registerTool(id, render) {
|
|
108
|
+
renderers.set(id, render);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Whether the pane is open on that tool right now. */
|
|
112
|
+
export function isToolOpen(id) {
|
|
113
|
+
return openTool === id;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* What a tool has to say on its tab: `unread` is what is waiting for the user in it, and `empty` is whether
|
|
118
|
+
* it has anything to show at all - which is what an untouched pane reads to decide whether to stay out of
|
|
119
|
+
* the way.
|
|
120
|
+
*/
|
|
121
|
+
export function noteTool(id, { unread = 0, empty = false } = {}) {
|
|
122
|
+
marks.set(id, { unread, empty });
|
|
123
|
+
renderShell();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Open a tool, drawing its panel. A tool the user chose is the tool this window opens on next time. */
|
|
127
|
+
export function showTool(id) {
|
|
128
|
+
if (!TOOLS.some((tool) => tool.id === id)) return;
|
|
129
|
+
openTool = id;
|
|
130
|
+
remember();
|
|
131
|
+
renderShell();
|
|
132
|
+
renderers.get(id)?.();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function drawTabs() {
|
|
136
|
+
const tabs = TOOLS.map((tool) => {
|
|
137
|
+
const button = document.createElement("button");
|
|
138
|
+
button.type = "button";
|
|
139
|
+
button.className = tool.id === openTool ? "tool-tab on" : "tool-tab";
|
|
140
|
+
button.dataset.tool = tool.id;
|
|
141
|
+
button.append(tool.label);
|
|
142
|
+
// What is waiting in a tool nobody is looking at. The one thing a tab says beyond its name, so a tool
|
|
143
|
+
// never has to take the screen to be noticed.
|
|
144
|
+
const waiting = (marks.get(tool.id) ?? EMPTY_MARK).unread;
|
|
145
|
+
if (waiting > 0) {
|
|
146
|
+
const badge = document.createElement("span");
|
|
147
|
+
badge.className = "tool-mark";
|
|
148
|
+
badge.textContent = String(waiting);
|
|
149
|
+
button.append(badge);
|
|
150
|
+
}
|
|
151
|
+
button.addEventListener("click", () => showTool(tool.id));
|
|
152
|
+
return button;
|
|
153
|
+
});
|
|
154
|
+
toolTabs.replaceChildren(...tabs);
|
|
155
|
+
for (const tool of TOOLS) tool.panel.hidden = tool.id !== openTool;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// --- How wide ----------------------------------------------------------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
// How wide, put away, or the whole row. Its own function because a drag is the one thing that changes the
|
|
161
|
+
// pane without changing anything in it: sixty of these a second are cheap, and sixty full redraws are not.
|
|
162
|
+
function applyGeometry(collapsedNow = shut) {
|
|
163
|
+
pane.classList.toggle("collapsed", collapsedNow);
|
|
164
|
+
// Only one of the two can be true: a pane that is put away has no width to be full of.
|
|
165
|
+
pane.classList.toggle("full", full && !collapsedNow);
|
|
166
|
+
// The width goes into a custom property rather than straight onto `width`, so the stylesheet keeps the
|
|
167
|
+
// last word: the collapsed spine, the full row and the narrow-screen layout are rules, and an inline
|
|
168
|
+
// width would beat all three.
|
|
169
|
+
pane.style.setProperty("--pane-width", width === null ? `${DEFAULT_FRACTION * 100}%` : `${width}px`);
|
|
170
|
+
paneFull.classList.toggle("on", full && !collapsedNow);
|
|
171
|
+
const back = full && !collapsedNow;
|
|
172
|
+
paneFull.title = back ? "Give the terminal its room back" : "Full width";
|
|
173
|
+
paneFull.setAttribute("aria-label", paneFull.title);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Draw the pane's own chrome: the tabs, the width, and what the spine says while the pane is away. */
|
|
177
|
+
export function renderShell() {
|
|
178
|
+
const mark = marks.get(openTool) ?? EMPTY_MARK;
|
|
179
|
+
shut = collapsed === null ? mark.empty : collapsed;
|
|
180
|
+
pane.hidden = false;
|
|
181
|
+
// There is no width worth choosing for a tool with nothing in it.
|
|
182
|
+
grip.hidden = mark.empty && shut;
|
|
183
|
+
|
|
184
|
+
applyGeometry(shut);
|
|
185
|
+
drawTabs();
|
|
186
|
+
|
|
187
|
+
// The spine stands for the whole pane, so it carries what every tool is waiting on, not just the open one.
|
|
188
|
+
let waiting = 0;
|
|
189
|
+
for (const entry of marks.values()) waiting += entry.unread;
|
|
190
|
+
spineBadge.textContent = waiting > 0 ? String(waiting) : "";
|
|
191
|
+
spineBadge.hidden = waiting === 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Draw the pane for the first time, on the tool this window was left on. Called from the entry rather than
|
|
196
|
+
* here, because the tools register as they load and there is no pane to draw until they all have.
|
|
197
|
+
*/
|
|
198
|
+
export function mountPane() {
|
|
199
|
+
renderShell();
|
|
200
|
+
renderers.get(openTool)?.();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Whether the pane is drawn as the spine rather than as a pane. */
|
|
204
|
+
export function isPaneShut() {
|
|
205
|
+
return shut;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Whether the user put the pane away, as opposed to never having said. */
|
|
209
|
+
export function isPaneCollapsed() {
|
|
210
|
+
return collapsed === true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Away, and back. Two ways in and one way out, because the pane is put away far more often than it is
|
|
214
|
+
// resized: the button in the bar is the obvious one, and dragging the grip past the threshold is the other.
|
|
215
|
+
export function setPaneCollapsed(next) {
|
|
216
|
+
collapsed = next;
|
|
217
|
+
shut = next;
|
|
218
|
+
if (collapsed) {
|
|
219
|
+
// Coming back to a pane that fills the window, with the terminal nowhere, is not what putting one
|
|
220
|
+
// away asks for. The width it had is remembered; the far end is not.
|
|
221
|
+
full = false;
|
|
222
|
+
} else {
|
|
223
|
+
// Back at whatever width it had, unless that width would be a sliver - a pane dragged shut, or one
|
|
224
|
+
// from a window remembered narrower than this one, has to open into something a page fits in.
|
|
225
|
+
const floor = Math.min(window.innerWidth * MIN_OPEN_FRACTION, window.innerWidth * MAX_FRACTION);
|
|
226
|
+
if (width !== null && width < floor) width = floor;
|
|
227
|
+
}
|
|
228
|
+
remember();
|
|
229
|
+
renderShell();
|
|
230
|
+
renderers.get(openTool)?.();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// The far end, and back. A press either way, and a drag of the grip is the other way back.
|
|
234
|
+
function setFull(next) {
|
|
235
|
+
full = next;
|
|
236
|
+
if (full) collapsed = false;
|
|
237
|
+
remember();
|
|
238
|
+
renderShell();
|
|
239
|
+
renderers.get(openTool)?.();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
paneFull.addEventListener("click", () => setFull(!full));
|
|
243
|
+
paneCollapse.addEventListener("click", () => setPaneCollapsed(true));
|
|
244
|
+
spine.addEventListener("click", () => setPaneCollapsed(false));
|
|
245
|
+
|
|
246
|
+
// --- Saying that something arrived -------------------------------------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
// One pulse of the pane's edge, in the workspace colour. ARRIVAL_MS must match the keyframes in styles.css.
|
|
249
|
+
const ARRIVAL_MS = 1_000;
|
|
250
|
+
let arrivalTimer = null;
|
|
251
|
+
|
|
252
|
+
// The class comes off on a timer rather than on animationend, because with a reduced-motion preference there
|
|
253
|
+
// is no animation to end - the stylesheet holds the highlight instead of pulsing it, and this is what makes
|
|
254
|
+
// the two last the same moment.
|
|
255
|
+
export function flashPane() {
|
|
256
|
+
clearTimeout(arrivalTimer);
|
|
257
|
+
// A pulse still running when the next page lands restarts rather than stacking: off, force the browser to
|
|
258
|
+
// notice, on again. Reading a layout property is what makes the restart real.
|
|
259
|
+
pane.classList.remove("arriving");
|
|
260
|
+
void pane.offsetWidth;
|
|
261
|
+
pane.classList.add("arriving");
|
|
262
|
+
arrivalTimer = setTimeout(() => pane.classList.remove("arriving"), ARRIVAL_MS);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// --- The divider -------------------------------------------------------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
// Dragging sets a pixel width until it stops making sense, and below the threshold the pane becomes the
|
|
268
|
+
// spine rather than a sliver. Pointer capture keeps the drag alive over the terminal and the frame alike -
|
|
269
|
+
// a page is a document with its own event loop, and without capture the pointer would be lost in it.
|
|
270
|
+
let dragging = false;
|
|
271
|
+
// The width the pane had when the drag started. Dragging it shut is a request to put it away, not a request
|
|
272
|
+
// for a 150px pane, so crossing the threshold hands its old width back rather than keeping the sliver the
|
|
273
|
+
// pointer passed through on the way there.
|
|
274
|
+
let widthBeforeDrag = null;
|
|
275
|
+
|
|
276
|
+
grip.addEventListener("pointerdown", (event) => {
|
|
277
|
+
dragging = true;
|
|
278
|
+
widthBeforeDrag = width;
|
|
279
|
+
document.body.classList.add("dragging");
|
|
280
|
+
grip.setPointerCapture(event.pointerId);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
grip.addEventListener("pointermove", (event) => {
|
|
284
|
+
if (!dragging) return;
|
|
285
|
+
// A drag is a width, so it is the way out of the far end as well as the way into any other width.
|
|
286
|
+
full = false;
|
|
287
|
+
const wanted = Math.max(0, window.innerWidth - event.clientX - grip.offsetWidth / 2);
|
|
288
|
+
if (wanted < COLLAPSE_AT) {
|
|
289
|
+
collapsed = true;
|
|
290
|
+
width = widthBeforeDrag;
|
|
291
|
+
} else {
|
|
292
|
+
collapsed = false;
|
|
293
|
+
width = Math.min(wanted, window.innerWidth * MAX_FRACTION);
|
|
294
|
+
}
|
|
295
|
+
// A drag is an answer too, so what it decides is what the next render starts from.
|
|
296
|
+
shut = collapsed;
|
|
297
|
+
applyGeometry(shut);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
function endDrag() {
|
|
301
|
+
if (!dragging) return;
|
|
302
|
+
dragging = false;
|
|
303
|
+
document.body.classList.remove("dragging");
|
|
304
|
+
remember();
|
|
305
|
+
// A drag that crossed the threshold changed which of the three states the pane is in, and the tool that
|
|
306
|
+
// is open in it has a bar and a document to lay out again at the new width.
|
|
307
|
+
renderShell();
|
|
308
|
+
renderers.get(openTool)?.();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
grip.addEventListener("pointerup", endDrag);
|
|
312
|
+
grip.addEventListener("pointercancel", endDrag);
|
|
313
|
+
|
|
314
|
+
// The bar is chrome: a click on it must not leave the terminal without focus for no reason.
|
|
315
|
+
panebar.addEventListener("mousedown", (event) => event.preventDefault());
|
|
@@ -1,58 +1,47 @@
|
|
|
1
|
-
// pane.js - the
|
|
1
|
+
// pane.js - the Pages tool: the pages an agent published, beside the terminal it published them from.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// outside its frame.
|
|
3
|
+
// One of the tools the pane holds (pane-shell.js holds them), and the whole of what this module draws is its
|
|
4
|
+
// own panel - the chips bar, the counter, and the page. The pane's own furniture, the tab bar and the width,
|
|
5
|
+
// is the shell's, so nothing here can move the pane or say which tool the user is looking at.
|
|
7
6
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// is
|
|
7
|
+
// The chips bar is chrome this interface draws around a page it does not trust (pane-frame.js renders that
|
|
8
|
+
// part). Keeping the two apart is the point: a published page can never fake which session and which page the
|
|
9
|
+
// user is looking at, because everything that says so is drawn outside its frame.
|
|
10
|
+
//
|
|
11
|
+
// What this tool shows belongs to the attached session, and it follows the tabs: switching sessions switches
|
|
12
|
+
// the list, the selection, and the page.
|
|
11
13
|
//
|
|
12
14
|
// A page that arrives opens itself, and says so with one pulse of the pane's edge. It never takes the
|
|
13
15
|
// keyboard: the caret stays in the composer mid-sentence, which is the price of the pane opening at all.
|
|
14
16
|
// "Arrives" is the server's own unread flag, not "new to this window" - the list here starts empty on every
|
|
15
17
|
// reload, and a page nobody has opened in a session nobody has visited is still an arrival when they get to
|
|
16
18
|
// it. A page for a session this window is not looking at touches nothing here; it marks that session's tab
|
|
17
|
-
// (see tabs.js), keeps its chip badge and its place in the spine's counter, and waits.
|
|
19
|
+
// (see tabs.js), keeps its chip badge and its place in the spine's counter, and waits. A page that lands
|
|
20
|
+
// while the pane is open on another tool marks the Pages tab and waits there: an arrival is worth saying, and
|
|
21
|
+
// never worth taking the screen for.
|
|
18
22
|
//
|
|
19
|
-
// The
|
|
23
|
+
// The tool exists before any of that. A session that has published nothing still has its tab, and opening it
|
|
20
24
|
// says what the pane is for and what to ask for - a feature nobody can see is a feature nobody uses, and this
|
|
21
25
|
// one is asked for in words rather than found in a menu.
|
|
22
26
|
|
|
23
27
|
import { sendFrame } from "./connection.js";
|
|
24
|
-
import {
|
|
25
|
-
chips,
|
|
26
|
-
grip,
|
|
27
|
-
pageBigger,
|
|
28
|
-
pagebar,
|
|
29
|
-
pageCount,
|
|
30
|
-
pageDoc,
|
|
31
|
-
pageSizeVal,
|
|
32
|
-
pageSmaller,
|
|
33
|
-
pane,
|
|
34
|
-
paneCollapse,
|
|
35
|
-
paneNext,
|
|
36
|
-
panePrev,
|
|
37
|
-
spine,
|
|
38
|
-
spineBadge,
|
|
39
|
-
} from "./dom.js";
|
|
28
|
+
import { chips, pageBigger, pagebar, pageCount, pageDoc, pageSizeVal, pageSmaller, paneNext, panePrev, toolPages } from "./dom.js";
|
|
40
29
|
import { arrivingPage } from "./pane-arrival.js";
|
|
41
30
|
import { dropPage, fetchPage, renderPage, submitFeedback } from "./pane-frame.js";
|
|
31
|
+
import {
|
|
32
|
+
flashPane,
|
|
33
|
+
isPaneCollapsed,
|
|
34
|
+
isPaneShut,
|
|
35
|
+
isToolOpen,
|
|
36
|
+
noteTool,
|
|
37
|
+
PAGES,
|
|
38
|
+
registerTool,
|
|
39
|
+
rememberPageSize,
|
|
40
|
+
setPaneCollapsed,
|
|
41
|
+
storedPageSize,
|
|
42
|
+
} from "./pane-shell.js";
|
|
42
43
|
import { attachedSid } from "./state.js";
|
|
43
44
|
|
|
44
|
-
// Narrower than this and the pane is not a pane any more, so it becomes the spine instead. The mock's
|
|
45
|
-
// threshold, and the width the spine itself takes.
|
|
46
|
-
const COLLAPSE_AT = 150;
|
|
47
|
-
// What the pane opens at, in a fraction of the window, and the most of the window a drag may give it.
|
|
48
|
-
const DEFAULT_FRACTION = 0.44;
|
|
49
|
-
const MAX_FRACTION = 0.7;
|
|
50
|
-
// The least of the window the pane may come back into. Reopening is not the same as resizing: whatever width
|
|
51
|
-
// it was put away at, a pane you have just asked for has to be wide enough to read a page in.
|
|
52
|
-
const MIN_OPEN_FRACTION = 1 / 3;
|
|
53
|
-
// The window's own pane preferences: how wide, whether it is collapsed, and how big a page is drawn in it.
|
|
54
|
-
// Not the session's - a pane is furniture, and furniture does not move because you looked at another tab.
|
|
55
|
-
const GEOMETRY_KEY = "webterm-pane";
|
|
56
45
|
// The sizes a page can be shown at, smallest first, and where an untouched window starts. The spacing is
|
|
57
46
|
// deliberately uneven: a correction near normal reading size wants a fine step, and someone going to 150%
|
|
58
47
|
// wants to get there in two presses.
|
|
@@ -66,39 +55,13 @@ const AGE_TICK_MS = 30_000;
|
|
|
66
55
|
// heard about, so switching back to a tab lands on the page it was left on.
|
|
67
56
|
const panes = new Map();
|
|
68
57
|
|
|
69
|
-
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
let
|
|
74
|
-
// Which of TEXT_SIZES the shown page is drawn at.
|
|
75
|
-
let sizeStep = DEFAULT_SIZE_STEP;
|
|
76
|
-
// What the last render decided, for the two things that only need to know whether the pane is showing.
|
|
77
|
-
let shut = true;
|
|
78
|
-
|
|
79
|
-
// --- What the window remembers -----------------------------------------------------------------------------------------------------------
|
|
80
|
-
|
|
81
|
-
try {
|
|
82
|
-
const stored = JSON.parse(localStorage.getItem(GEOMETRY_KEY) ?? "{}");
|
|
83
|
-
if (typeof stored.width === "number" && stored.width >= COLLAPSE_AT) width = stored.width;
|
|
84
|
-
if (typeof stored.collapsed === "boolean") collapsed = stored.collapsed;
|
|
85
|
-
// The multiplier is stored rather than its position, so a window that was left at a size this version no
|
|
86
|
-
// longer offers falls back to the default instead of landing between two steps.
|
|
87
|
-
const step = TEXT_SIZES.indexOf(stored.size);
|
|
88
|
-
if (step !== -1) sizeStep = step;
|
|
89
|
-
} catch {
|
|
90
|
-
// A disabled or full store only means the pane opens at its default width.
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function rememberGeometry() {
|
|
94
|
-
try {
|
|
95
|
-
localStorage.setItem(GEOMETRY_KEY, JSON.stringify({ width, collapsed, size: TEXT_SIZES[sizeStep] }));
|
|
96
|
-
} catch {
|
|
97
|
-
// The pane still works for this page's lifetime.
|
|
98
|
-
}
|
|
99
|
-
}
|
|
58
|
+
// Which of TEXT_SIZES the shown page is drawn at. The multiplier is what the pane stores, so a window that
|
|
59
|
+
// was left at a size this version no longer offers falls back to the default instead of landing between two
|
|
60
|
+
// steps.
|
|
61
|
+
const storedStep = TEXT_SIZES.indexOf(storedPageSize());
|
|
62
|
+
let sizeStep = storedStep === -1 ? DEFAULT_SIZE_STEP : storedStep;
|
|
100
63
|
|
|
101
|
-
// --- The
|
|
64
|
+
// --- The tool's own state ----------------------------------------------------------------------------------------------------------------
|
|
102
65
|
|
|
103
66
|
function paneFor(sid) {
|
|
104
67
|
let entry = panes.get(sid);
|
|
@@ -122,14 +85,16 @@ export function applyPages(msg) {
|
|
|
122
85
|
// The page this frame opens, if it opens one - the rule itself is in pane-arrival.js, where it can be run,
|
|
123
86
|
// and so is what a burst of publishes does under it.
|
|
124
87
|
const arrived = arrivingPage(entry.pages);
|
|
88
|
+
// It is selected either way, so the tab the user comes back to is on the page that arrived. Only the
|
|
89
|
+
// saying-so is held back while another tool has the pane.
|
|
125
90
|
if (arrived !== null) {
|
|
126
91
|
select(msg.sid, arrived.id, { user: false });
|
|
127
|
-
|
|
92
|
+
if (isToolOpen(PAGES)) flashPane();
|
|
128
93
|
}
|
|
129
94
|
// A pane someone put away comes back for a page. Only for an answer they gave: the third state, nobody
|
|
130
95
|
// has said, already opens the pane when its session stops being empty, and settling it here would open
|
|
131
96
|
// the pane in every other empty session in this window too.
|
|
132
|
-
if (arrived !== null &&
|
|
97
|
+
if (arrived !== null && isToolOpen(PAGES) && isPaneCollapsed()) setPaneCollapsed(false);
|
|
133
98
|
else render();
|
|
134
99
|
}
|
|
135
100
|
|
|
@@ -141,7 +106,7 @@ export function prunePanes(live) {
|
|
|
141
106
|
}
|
|
142
107
|
|
|
143
108
|
/**
|
|
144
|
-
* Draw the
|
|
109
|
+
* Draw the tool for whatever session this window is now on, asking for its pages when this window has never
|
|
145
110
|
* been told them. The server pushes the list when a window lands on a session, so this covers the frame that
|
|
146
111
|
* did not arrive - a socket replaced mid-attach, or a window that took a session over from another one.
|
|
147
112
|
*/
|
|
@@ -257,25 +222,16 @@ function chipFor(sid, page, selected) {
|
|
|
257
222
|
return chip;
|
|
258
223
|
}
|
|
259
224
|
|
|
260
|
-
// How wide, and collapsed or not. Its own function because a drag is the one thing that changes the pane
|
|
261
|
-
// without changing anything in it: sixty of these a second are cheap, and sixty full redraws are not.
|
|
262
|
-
function applyGeometry(collapsedNow = shut) {
|
|
263
|
-
pane.classList.toggle("collapsed", collapsedNow);
|
|
264
|
-
// The width goes into a custom property rather than straight onto `width`, so the stylesheet keeps the
|
|
265
|
-
// last word: the collapsed spine and the narrow-screen layout are rules, and an inline width would beat
|
|
266
|
-
// both of them.
|
|
267
|
-
pane.style.setProperty("--pane-width", width === null ? `${DEFAULT_FRACTION * 100}%` : `${width}px`);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
225
|
// --- How big a page is drawn -------------------------------------------------------------------------------------------------------------
|
|
271
226
|
|
|
272
|
-
// The size is a property on
|
|
273
|
-
// the frame's document has an opaque origin and the shell cannot reach into it, and renderPage()
|
|
274
|
-
// iframe away and builds a fresh one on every chip click, so anything set on the frame itself
|
|
275
|
-
// put back on every one of those paths. Setting it out here costs nothing and cannot be
|
|
227
|
+
// The size is a property on this tool's panel, and the stylesheet zooms the frame from it. Two reasons, both
|
|
228
|
+
// structural: the frame's document has an opaque origin and the shell cannot reach into it, and renderPage()
|
|
229
|
+
// throws the iframe away and builds a fresh one on every chip click, so anything set on the frame itself
|
|
230
|
+
// would have to be put back on every one of those paths. Setting it out here costs nothing and cannot be
|
|
231
|
+
// missed.
|
|
276
232
|
function applyTextSize() {
|
|
277
233
|
const size = TEXT_SIZES[sizeStep];
|
|
278
|
-
|
|
234
|
+
toolPages.style.setProperty("--page-zoom", String(size));
|
|
279
235
|
pageSizeVal.textContent = `${Math.round(size * 100)}%`;
|
|
280
236
|
// A control that can do nothing says so, rather than swallowing the press.
|
|
281
237
|
pageSmaller.disabled = sizeStep === 0;
|
|
@@ -287,49 +243,25 @@ function stepTextSize(by) {
|
|
|
287
243
|
if (next === sizeStep) return;
|
|
288
244
|
sizeStep = next;
|
|
289
245
|
applyTextSize();
|
|
290
|
-
|
|
246
|
+
rememberPageSize(TEXT_SIZES[sizeStep]);
|
|
291
247
|
}
|
|
292
248
|
|
|
293
249
|
pageSmaller.addEventListener("click", () => stepTextSize(-1));
|
|
294
250
|
pageBigger.addEventListener("click", () => stepTextSize(1));
|
|
295
251
|
applyTextSize();
|
|
296
252
|
|
|
297
|
-
// --- Saying that a page arrived ----------------------------------------------------------------------------------------------------------
|
|
298
|
-
|
|
299
|
-
// One pulse of the pane's edge, in the workspace colour. ARRIVAL_MS must match the keyframes in styles.css.
|
|
300
|
-
const ARRIVAL_MS = 1_000;
|
|
301
|
-
let arrivalTimer = null;
|
|
302
|
-
|
|
303
|
-
// The class comes off on a timer rather than on animationend, because with a reduced-motion preference there
|
|
304
|
-
// is no animation to end - the stylesheet holds the highlight instead of pulsing it, and this is what makes
|
|
305
|
-
// the two last the same moment.
|
|
306
|
-
function flashArrival() {
|
|
307
|
-
clearTimeout(arrivalTimer);
|
|
308
|
-
// A pulse still running when the next page lands restarts rather than stacking: off, force the browser to
|
|
309
|
-
// notice, on again. Reading a layout property is what makes the restart real.
|
|
310
|
-
pane.classList.remove("arriving");
|
|
311
|
-
void pane.offsetWidth;
|
|
312
|
-
pane.classList.add("arriving");
|
|
313
|
-
arrivalTimer = setTimeout(() => pane.classList.remove("arriving"), ARRIVAL_MS);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
253
|
function render() {
|
|
317
254
|
const sid = attachedSid;
|
|
318
255
|
const entry = sid === null ? null : (panes.get(sid) ?? null);
|
|
319
256
|
const pages = entry?.pages ?? [];
|
|
320
257
|
const unread = pages.filter((page) => page.unread).length;
|
|
321
258
|
|
|
322
|
-
// Nothing published in this session, and nothing to say about it. The
|
|
323
|
-
// someone opens it - because a pane that only appears once an agent has used it is one
|
|
324
|
-
// ask for.
|
|
259
|
+
// Nothing published in this session, and nothing to say about it. The tab stays - and the pane stays as
|
|
260
|
+
// the spine, until someone opens it - because a pane that only appears once an agent has used it is one
|
|
261
|
+
// nobody knows to ask for. Saying so is the shell's business: the tab's mark, and whether an untouched
|
|
262
|
+
// pane keeps out of the way, are the pane's decisions to take from what its open tool has.
|
|
325
263
|
const bare = pages.length === 0 && !entry?.notice;
|
|
326
|
-
|
|
327
|
-
pane.hidden = false;
|
|
328
|
-
grip.hidden = bare && shut;
|
|
329
|
-
|
|
330
|
-
applyGeometry(shut);
|
|
331
|
-
spineBadge.textContent = unread > 0 ? String(unread) : "";
|
|
332
|
-
spineBadge.hidden = unread === 0;
|
|
264
|
+
noteTool(PAGES, { unread, empty: bare });
|
|
333
265
|
|
|
334
266
|
chips.replaceChildren(...pages.map((page) => chipFor(sid, page, page.id === entry?.selected)));
|
|
335
267
|
const at = pages.findIndex((page) => page.id === entry?.selected);
|
|
@@ -364,75 +296,18 @@ function render() {
|
|
|
364
296
|
chips.querySelector(".chip.sel")?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
365
297
|
}
|
|
366
298
|
|
|
299
|
+
// The tool draws itself whenever the pane asks - a tab click, a width that changed, a session switch.
|
|
300
|
+
registerTool(PAGES, render);
|
|
301
|
+
|
|
367
302
|
setInterval(() => {
|
|
368
|
-
if (!
|
|
303
|
+
if (!isPaneShut() && isToolOpen(PAGES)) render();
|
|
369
304
|
}, AGE_TICK_MS);
|
|
370
305
|
|
|
371
|
-
// --- The divider, and the spine ----------------------------------------------------------------------------------------------------------
|
|
372
|
-
|
|
373
|
-
// Dragging sets a pixel width until it stops making sense, and below the threshold the pane becomes the
|
|
374
|
-
// spine rather than a sliver. Pointer capture keeps the drag alive over the terminal and the frame alike -
|
|
375
|
-
// a page is a document with its own event loop, and without capture the pointer would be lost in it.
|
|
376
|
-
let dragging = false;
|
|
377
|
-
// The width the pane had when the drag started. Dragging it shut is a request to put it away, not a request
|
|
378
|
-
// for a 150px pane, so crossing the threshold hands its old width back rather than keeping the sliver the
|
|
379
|
-
// pointer passed through on the way there.
|
|
380
|
-
let widthBeforeDrag = null;
|
|
381
|
-
|
|
382
|
-
grip.addEventListener("pointerdown", (event) => {
|
|
383
|
-
dragging = true;
|
|
384
|
-
widthBeforeDrag = width;
|
|
385
|
-
document.body.classList.add("dragging");
|
|
386
|
-
grip.setPointerCapture(event.pointerId);
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
grip.addEventListener("pointermove", (event) => {
|
|
390
|
-
if (!dragging) return;
|
|
391
|
-
const wanted = Math.max(0, window.innerWidth - event.clientX - grip.offsetWidth / 2);
|
|
392
|
-
if (wanted < COLLAPSE_AT) {
|
|
393
|
-
collapsed = true;
|
|
394
|
-
width = widthBeforeDrag;
|
|
395
|
-
} else {
|
|
396
|
-
collapsed = false;
|
|
397
|
-
width = Math.min(wanted, window.innerWidth * MAX_FRACTION);
|
|
398
|
-
}
|
|
399
|
-
// A drag is an answer too, so what it decides is what the next render starts from.
|
|
400
|
-
shut = collapsed;
|
|
401
|
-
applyGeometry(shut);
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
function endDrag() {
|
|
405
|
-
if (!dragging) return;
|
|
406
|
-
dragging = false;
|
|
407
|
-
document.body.classList.remove("dragging");
|
|
408
|
-
rememberGeometry();
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
grip.addEventListener("pointerup", endDrag);
|
|
412
|
-
grip.addEventListener("pointercancel", endDrag);
|
|
413
|
-
|
|
414
|
-
// Away, and back. Two ways in and one way out, because the pane is put away far more often than it is
|
|
415
|
-
// resized: the button in the bar is the obvious one, and dragging the grip past the threshold is the other.
|
|
416
|
-
function setCollapsed(next) {
|
|
417
|
-
collapsed = next;
|
|
418
|
-
shut = next;
|
|
419
|
-
// Back at whatever width it had, unless that width would be a sliver - a pane dragged shut, or one from
|
|
420
|
-
// a window remembered narrower than this one, has to open into something a page fits in.
|
|
421
|
-
if (!collapsed) {
|
|
422
|
-
const floor = Math.min(window.innerWidth * MIN_OPEN_FRACTION, window.innerWidth * MAX_FRACTION);
|
|
423
|
-
if (width !== null && width < floor) width = floor;
|
|
424
|
-
}
|
|
425
|
-
rememberGeometry();
|
|
426
|
-
render();
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
paneCollapse.addEventListener("click", () => setCollapsed(true));
|
|
430
|
-
spine.addEventListener("click", () => setCollapsed(false));
|
|
431
|
-
|
|
432
|
-
// The chips bar scrolls by roughly a bar's worth, which is what the arrows in the mock do.
|
|
433
|
-
panePrev.addEventListener("click", () => chips.scrollBy({ left: -chips.clientWidth * 0.8, behavior: "smooth" }));
|
|
434
|
-
paneNext.addEventListener("click", () => chips.scrollBy({ left: chips.clientWidth * 0.8, behavior: "smooth" }));
|
|
435
306
|
// A bar that fits at one width may not at another, and the arrows only exist when it does not.
|
|
436
307
|
window.addEventListener("resize", render);
|
|
308
|
+
// The bar hides its scrollbar, so these are the only way to reach a chip that scrolled out of it. A little
|
|
309
|
+
// under a full bar each time, so the chip at the edge stays on the screen and says where the bar now is.
|
|
310
|
+
panePrev.addEventListener("click", () => chips.scrollBy({ left: -chips.clientWidth * 0.8, behavior: "smooth" }));
|
|
311
|
+
paneNext.addEventListener("click", () => chips.scrollBy({ left: chips.clientWidth * 0.8, behavior: "smooth" }));
|
|
437
312
|
// The pagebar is chrome: a click on it must not leave the terminal without focus for no reason.
|
|
438
313
|
pagebar.addEventListener("mousedown", (event) => event.preventDefault());
|