chamba 0.6.0 → 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/commands/dev.js +11 -11
- package/dist/commands/settings.js +1 -1
- package/dist/lib/agent-context.js +33 -7
- package/dist/lib/chamba-yaml.js +1 -1
- package/dist/lib/constants.js +4 -4
- package/dist/lib/dockerfile-builder.js +2 -1
- package/dist/lib/ports.js +4 -4
- package/dist/lib/safe-rm.js +13 -3
- package/dist/lib/webterm.js +7 -7
- package/package.json +3 -5
- package/templates/Dockerfile +20 -1
- package/templates/context/web-pane-craft.md +1 -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/chamba-statusline/SKILL.md +1 -1
- 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 +50 -14
- package/templates/webterm/artifacts.js +11 -11
- package/templates/webterm/config.js +52 -9
- package/templates/webterm/conversation.js +3 -3
- package/templates/webterm/pane.js +14 -3
- package/templates/webterm/proc.js +1 -1
- package/templates/webterm/public/app/alerts.js +5 -5
- package/templates/webterm/public/app/composer.js +5 -2
- package/templates/webterm/public/app/connection.js +2 -2
- package/templates/webterm/public/app/dictation.js +1 -1
- package/templates/webterm/public/app/dom.js +13 -5
- package/templates/webterm/public/app/frames.js +8 -1
- package/templates/webterm/public/app/main.js +8 -2
- package/templates/webterm/public/app/new-session.js +1 -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/state.js +1 -1
- package/templates/webterm/public/app/tabs.js +1 -1
- 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 +300 -11
- package/templates/webterm/sessions.js +7 -7
- package/templates/webterm/snapshot.js +2 -2
- package/templates/webterm/specs.js +358 -0
- package/templates/webterm/tool-document.js +67 -0
- package/templates/webterm/typed-line.js +85 -0
|
@@ -18,7 +18,7 @@ import { WORKSPACE_COLOR } from "./theme.js";
|
|
|
18
18
|
// state of the container in one mark:
|
|
19
19
|
//
|
|
20
20
|
// nothing - nothing is happening in here
|
|
21
|
-
// white bar across the bottom - an agent is working, with a lit segment
|
|
21
|
+
// white bar across the bottom - an agent is working, with a lit segment moving along it
|
|
22
22
|
// green dot in the top corner - an agent finished and is waiting for you, pulsing until you go and look
|
|
23
23
|
//
|
|
24
24
|
// One mark at a time, and the two are told apart by where they sit before any colour is read: working owns the
|
|
@@ -46,7 +46,7 @@ const ICON_UNITS = 32;
|
|
|
46
46
|
const ICON_TICK_MS = 240;
|
|
47
47
|
// The waiting dot's bright-to-dim step, in ticks. Slow enough to read as a pulse rather than a flicker.
|
|
48
48
|
const PULSE_TICKS = 3;
|
|
49
|
-
// One out-and-back of the working segment, in ticks - about two seconds each way,
|
|
49
|
+
// One out-and-back of the working segment, in ticks - about two seconds each way, a glide and not a dash.
|
|
50
50
|
const SWEEP_TICKS = 16;
|
|
51
51
|
const DONE_COLOR = "#2fe58a";
|
|
52
52
|
const BUSY_COLOR = "#e6edf3";
|
|
@@ -69,7 +69,7 @@ function pulseDim() {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
// Where the working segment is on its track - 0 at one end, 1 at the other, and back down again. A triangle
|
|
72
|
-
// rather than a saw, so the segment
|
|
72
|
+
// rather than a saw, so the segment travels back instead of jumping to the start.
|
|
73
73
|
function sweepAt() {
|
|
74
74
|
const half = SWEEP_TICKS / 2;
|
|
75
75
|
const step = iconFrame % SWEEP_TICKS;
|
|
@@ -132,7 +132,7 @@ function drawIcon(ctx, badgeColor, dim, sweep) {
|
|
|
132
132
|
ctx.fill();
|
|
133
133
|
ctx.stroke();
|
|
134
134
|
|
|
135
|
-
// Working: a bar across the bottom, with a lit segment
|
|
135
|
+
// Working: a bar across the bottom, with a lit segment moving from end to end. The bottom edge is the far
|
|
136
136
|
// side of the icon from the corner waiting owns, which is what tells the two states apart at 16px - a thin
|
|
137
137
|
// line down the right edge, which is what this used to be, reads as a scrollbar rather than as anything the
|
|
138
138
|
// container is doing. The track is 20 wide and the segment 8, so the segment travels the 12 between them.
|
|
@@ -321,7 +321,7 @@ function playChime() {
|
|
|
321
321
|
const ctx = audio();
|
|
322
322
|
// Blocked until this page has been clicked or typed into. Nearly never true here - you click the terminal to type -
|
|
323
323
|
// and the tab, the title and the icon are all still saying it either way.
|
|
324
|
-
if (
|
|
324
|
+
if (ctx?.state !== "running") return;
|
|
325
325
|
const at = ctx.currentTime + 0.02;
|
|
326
326
|
for (const [ratio, level, decay] of CHIME_PARTIALS) {
|
|
327
327
|
const osc = ctx.createOscillator();
|
|
@@ -45,7 +45,7 @@ export function refreshComposer() {
|
|
|
45
45
|
|
|
46
46
|
export function autoGrow() {
|
|
47
47
|
input.style.height = "auto";
|
|
48
|
-
// The floor keeps the box off the button block behind it and the ceiling
|
|
48
|
+
// The floor keeps the box off the button block behind it and the ceiling limits it to seven lines. Both are
|
|
49
49
|
// read from the box itself rather than written here as well: the same two numbers in two files is how the
|
|
50
50
|
// box comes to spring back to a height the stylesheet no longer says.
|
|
51
51
|
const box = getComputedStyle(input);
|
|
@@ -161,7 +161,10 @@ sendBtn.addEventListener("click", send);
|
|
|
161
161
|
// draft is not dropped, the Up-arrow history is what you typed and does not gain this, and the terminal is not
|
|
162
162
|
// given the focus. Preventing the default on mousedown is what keeps the caret where it was: a button takes the
|
|
163
163
|
// focus when it is clicked, and this one has no use for it.
|
|
164
|
-
|
|
164
|
+
// It names no pane on purpose. Where a page belongs follows what the session is doing: a page made during
|
|
165
|
+
// spec work is an artifact of that work and is saved beside it, and anything else goes to Pages. The skills
|
|
166
|
+
// carry that rule, so the sentence asks for the page and leaves the destination to them.
|
|
167
|
+
const PANE_REQUEST = "Put your last answer on a page.";
|
|
165
168
|
|
|
166
169
|
askPageBtn.addEventListener("mousedown", (e) => e.preventDefault());
|
|
167
170
|
askPageBtn.addEventListener("click", () => {
|
|
@@ -23,7 +23,7 @@ export let everConnected = false;
|
|
|
23
23
|
//
|
|
24
24
|
// Four reasons a window ends up here, and they are not the same to the user:
|
|
25
25
|
// down - nothing answers. The container is stopped or gone; keeps reconnecting, so it heals itself.
|
|
26
|
-
// locked - the relay answered and refused this window's key. The interface restarted and
|
|
26
|
+
// locked - the relay answered and refused this window's key. The interface restarted and created a new
|
|
27
27
|
// one, so this URL is spent; reconnecting is pointless and stops.
|
|
28
28
|
// stopping - the user just stopped the container from here. Same end state as "down", but it is not a
|
|
29
29
|
// failure and must not read like one.
|
|
@@ -181,7 +181,7 @@ export function sendFrame(frame) {
|
|
|
181
181
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(frame));
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
// Any frame at all proves the socket works, which is what a wake
|
|
184
|
+
// Any frame at all proves the socket works, which is what a wake check is waiting to hear. Called by the frame
|
|
185
185
|
// router, since every frame counts and only it sees them all.
|
|
186
186
|
export function noteSocketAlive() {
|
|
187
187
|
if (probeTimer) {
|
|
@@ -64,7 +64,7 @@ if (!SpeechRecognition) {
|
|
|
64
64
|
|
|
65
65
|
// Prime the mic permission via getUserMedia first: SpeechRecognition on its own often fails
|
|
66
66
|
// straight to "not-allowed" without prompting. In an embedded webview getUserMedia is missing
|
|
67
|
-
// or blocked, so we
|
|
67
|
+
// or blocked, so we show the "use a real browser" hint.
|
|
68
68
|
async function startDictation() {
|
|
69
69
|
try {
|
|
70
70
|
if (!navigator.mediaDevices?.getUserMedia) {
|
|
@@ -17,10 +17,21 @@ export const askPageBtn = document.getElementById("ask-page");
|
|
|
17
17
|
export const fileInput = document.getElementById("file-input");
|
|
18
18
|
export const note = document.getElementById("note");
|
|
19
19
|
|
|
20
|
-
// The
|
|
21
|
-
//
|
|
20
|
+
// The pane's own chrome: the bar that says which tool is open and how wide the pane is, and the panel each
|
|
21
|
+
// tool draws into. None of it belongs to a tool, which is what keeps a tool from moving the pane.
|
|
22
22
|
export const grip = document.getElementById("grip");
|
|
23
23
|
export const pane = document.getElementById("pane");
|
|
24
|
+
export const panebar = document.getElementById("panebar");
|
|
25
|
+
export const toolTabs = document.getElementById("tool-tabs");
|
|
26
|
+
export const paneFull = document.getElementById("pane-full");
|
|
27
|
+
export const paneCollapse = document.getElementById("pane-collapse");
|
|
28
|
+
export const spine = document.getElementById("spine");
|
|
29
|
+
export const spineBadge = document.getElementById("spine-badge");
|
|
30
|
+
export const toolPages = document.getElementById("tool-pages");
|
|
31
|
+
export const toolSpecs = document.getElementById("tool-specs");
|
|
32
|
+
|
|
33
|
+
// The Pages tool. Every one of these is chrome the interface draws around a published page - the page itself
|
|
34
|
+
// lives in a frame built by app/pane-frame.js and is never queried back.
|
|
24
35
|
export const pagebar = document.getElementById("pagebar");
|
|
25
36
|
export const panePrev = document.getElementById("pane-prev");
|
|
26
37
|
export const paneNext = document.getElementById("pane-next");
|
|
@@ -29,10 +40,7 @@ export const pageCount = document.getElementById("page-count");
|
|
|
29
40
|
export const pageSmaller = document.getElementById("page-smaller");
|
|
30
41
|
export const pageBigger = document.getElementById("page-bigger");
|
|
31
42
|
export const pageSizeVal = document.getElementById("page-size-val");
|
|
32
|
-
export const paneCollapse = document.getElementById("pane-collapse");
|
|
33
43
|
export const pageDoc = document.getElementById("page-doc");
|
|
34
|
-
export const spine = document.getElementById("spine");
|
|
35
|
-
export const spineBadge = document.getElementById("spine-badge");
|
|
36
44
|
|
|
37
45
|
// The status strip. One element: everything in it is built per frame from the attached session's snapshot.
|
|
38
46
|
export const strip = document.getElementById("strip");
|
|
@@ -13,6 +13,7 @@ import { stopDictation } from "./dictation.js";
|
|
|
13
13
|
import { captureDraft, dropDraft, dropHistory, persistDrafts, pruneDrafts, pruneHistory, restoreDraft } from "./drafts.js";
|
|
14
14
|
import { noteMsg } from "./note.js";
|
|
15
15
|
import { applyPages, prunePanes, refreshPane } from "./pane.js";
|
|
16
|
+
import { applySpecs, applySpecsDelivery } from "./specs-host.js";
|
|
16
17
|
import { adoptSessionsFrame, attachedSid, defaultAgent, sessions, setAttachedSid } from "./state.js";
|
|
17
18
|
import { applySnapshot, pruneStatuses, refreshStrip } from "./status-strip.js";
|
|
18
19
|
import { noteArrivals, notePageArrivals, renderBar } from "./tabs.js";
|
|
@@ -28,7 +29,7 @@ let shownSid = null;
|
|
|
28
29
|
let shownMessage = null;
|
|
29
30
|
|
|
30
31
|
export function onFrame(msg) {
|
|
31
|
-
// Any frame at all proves the socket works, which is what a wake
|
|
32
|
+
// Any frame at all proves the socket works, which is what a wake check is waiting to hear.
|
|
32
33
|
noteSocketAlive();
|
|
33
34
|
if (msg.t === "sessions") {
|
|
34
35
|
applySessions(msg);
|
|
@@ -64,6 +65,12 @@ export function onFrame(msg) {
|
|
|
64
65
|
refreshStrip();
|
|
65
66
|
} else if (msg.t === "pages") {
|
|
66
67
|
applyPages(msg);
|
|
68
|
+
} else if (msg.t === "specs") {
|
|
69
|
+
// Every window gets these: a spec belongs to the repository rather than to a session.
|
|
70
|
+
applySpecs(msg);
|
|
71
|
+
} else if (msg.t === "specs:delivered") {
|
|
72
|
+
// What the server made of this window's own delivery, on its way back to the frame that asked.
|
|
73
|
+
applySpecsDelivery(msg);
|
|
67
74
|
} else if (msg.t === "snapshot") {
|
|
68
75
|
applySnapshot(msg);
|
|
69
76
|
} else if (msg.t === "out") {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// goes as one {t:"paste"} frame the server wraps as a bracketed paste.
|
|
14
14
|
//
|
|
15
15
|
// Everything here rides on the key this window was handed in its URL: the socket, the uploads and the
|
|
16
|
-
// status
|
|
16
|
+
// status check all carry it, and without a valid one the page is never served in the first place.
|
|
17
17
|
//
|
|
18
18
|
// The page is plain ES modules with no build step, one per subject:
|
|
19
19
|
//
|
|
@@ -28,7 +28,9 @@
|
|
|
28
28
|
// cards.js the overlay cards
|
|
29
29
|
// connection.js the socket, the reconnect loop, and the curtain
|
|
30
30
|
// frames.js what to do with each frame the server sends
|
|
31
|
-
// pane.js
|
|
31
|
+
// pane-shell.js the pane itself: the tool tabs, the three widths, the drag and the spine
|
|
32
|
+
// 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
|
|
32
34
|
// pane-frame.js a published page in a sandboxed frame, and the answer that comes back out of it
|
|
33
35
|
// pane-arrival.js which page an incoming list opens by itself, and when none of them does
|
|
34
36
|
// status-strip.js the attached session's model, context, quota meters and version, above the composer
|
|
@@ -67,10 +69,12 @@ import "./note.js";
|
|
|
67
69
|
import "./pane.js";
|
|
68
70
|
import "./pane-arrival.js";
|
|
69
71
|
import "./pane-frame.js";
|
|
72
|
+
import "./specs-host.js";
|
|
70
73
|
import "./state.js";
|
|
71
74
|
import "./status-strip.js";
|
|
72
75
|
import "./strip-format.js";
|
|
73
76
|
import "./theme.js";
|
|
77
|
+
import { mountPane } from "./pane-shell.js";
|
|
74
78
|
import { renderBar } from "./tabs.js";
|
|
75
79
|
import { mountTerminal, term } from "./terminal.js";
|
|
76
80
|
|
|
@@ -79,6 +83,8 @@ import { mountTerminal, term } from "./terminal.js";
|
|
|
79
83
|
mountTerminal();
|
|
80
84
|
installClipboard();
|
|
81
85
|
refreshComposer();
|
|
86
|
+
// After every tool has registered, which is what makes the pane something to draw.
|
|
87
|
+
mountPane();
|
|
82
88
|
renderBar();
|
|
83
89
|
// Before the first frame arrives, so the icon carries this workspace's frame from the moment the page loads.
|
|
84
90
|
refreshBrowserTab();
|
|
@@ -78,7 +78,7 @@ async function fillDirList(list) {
|
|
|
78
78
|
if (dir === "/") option.label = "workspace root";
|
|
79
79
|
list.append(option);
|
|
80
80
|
}
|
|
81
|
-
// The scan is
|
|
81
|
+
// The scan is limited, so say when the list is partial instead of letting it look complete.
|
|
82
82
|
if (payload?.truncated) noteMsg("the directory list is partial - deeper paths can still be typed in");
|
|
83
83
|
}
|
|
84
84
|
|
|
@@ -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());
|