Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

Merge the desktop app's settings and runner in the main window: the runner is a dot beside the title bar's Settings button with a menu of its own (its state, each kind of job and what it runs, Start and Stop, updates, the log), Settings opens as a dialog in the window with its sections down the side, the separate windows stay only for when the site isn't showing, and every button that asks the app for something shows it is working until something happens.

syntaqxcommitted Parentsb955164210b109Browse files
32 files+1928−2650/32 viewed
+82−0
1+// A button at work, in the app's own pages (settings.js, runner.js): from
2+// the click until what it asked for shows, a spinner before its label
3+// (settings.css `button.busy`), pressed no more, and said to be busy to a
4+// screen reader. What it asked for shows when the status that follows
5+// changes; an error, or 20 seconds with no change, gives the button back,
6+// so a press is never seen to do nothing and never leaves a button dead.
7+//
8+// A page that draws its buttons again on every status asks `pressed` while
9+// drawing: a button pressed with the status as it still is stays busy.
10+(function () {
11+ var LIMIT = 20000;
12+ var timers = new WeakMap();
13+ // What was pressed (by a key such as "retry" or "start:linux"), with the
14+ // status as it was then and when.
15+ var presses = {};
16+
17+ function start(button) {
18+ if (!button || button.classList.contains("busy")) return;
19+ button.disabled = true;
20+ button.classList.add("busy");
21+ button.setAttribute("aria-busy", "true");
22+ timers.set(
23+ button,
24+ setTimeout(function () {
25+ stop(button);
26+ }, LIMIT),
27+ );
28+ }
29+
30+ function stop(button) {
31+ if (!button || !button.classList.contains("busy")) return;
32+ clearTimeout(timers.get(button));
33+ timers.delete(button);
34+ button.disabled = false;
35+ button.classList.remove("busy");
36+ button.removeAttribute("aria-busy");
37+ }
38+
39+ function press(key, status) {
40+ presses[key] = { status: status, at: Date.now() };
41+ }
42+
43+ function release(key) {
44+ delete presses[key];
45+ }
46+
47+ // Whether a press is still waiting: the status is as it was, and not for long.
48+ function pressed(key, status) {
49+ var press = presses[key];
50+ if (!press) return false;
51+ if (press.status !== status || Date.now() - press.at >= LIMIT) {
52+ delete presses[key];
53+ return false;
54+ }
55+ return true;
56+ }
57+
58+ // Runs `work` (a function returning a promise) for a button: busy until
59+ // the status changes when `waits`, or until the work is done. Resolves
60+ // whether the work went through; `fail` is told why it did not.
61+ function run(button, key, status, waits, work, fail) {
62+ press(key, status);
63+ start(button);
64+ return work().then(
65+ function () {
66+ if (!waits) {
67+ release(key);
68+ stop(button);
69+ }
70+ return true;
71+ },
72+ function (error) {
73+ release(key);
74+ stop(button);
75+ if (fail) fail(error);
76+ return false;
77+ },
78+ );
79+ }
80+
81+ window.g1tBusy = Object.freeze({ LIMIT: LIMIT, start: start, stop: stop, press: press, release: release, pressed: pressed, run: run });
82+})();
+5−2
11 <!doctype html>
22 <!--
33 The runner, in a window of its own, apart from the settings: opened from
4− the chip beside the title bar's settings button, the tray, or the menu
5− (src/settings.rs open_runner). What it is doing, each kind of job it
4+ the dot beside the title bar's settings button, or the tray, while the
5+ window shows no page of the site's to draw the runner menu, and where
6+ jobs are taken out of the sandbox, which only a page bundled with the
7+ app may do (src/settings.rs runner_menu). What it is doing, each kind of job it
68 runs and the job each is running, how jobs run and for which workspace,
79 its updates and its log, live (runner.js). Its words are the app's
810 (src/runner/diagnosis.rs).
101103 <p class="muted small foot"><a href="https://docs.g1t.sh/guides/desktop/#the-runner">About the runner</a></p>
102104 </main>
103105 </div>
106+ <script src="busy.js"></script>
104107 <script src="runner.js"></script>
105108 </body>
106109 </html>
+68−89
4444 return item;
4545 }
4646
47− // A fix's button. Choosing is done here, in this window; a copy says it was copied.
48− function actionButton(action, primary) {
47+ var busy = window.g1tBusy;
48+
49+ // The whole runner's status, as far as a button waits on it: a press is
50+ // done when any of this changes (busy.js).
51+ function runnerStatus(runner) {
52+ var d = runner.diagnosis;
53+ var update = d.update || {};
54+ return JSON.stringify([d.code, d.tone, d.title, d.cause, runner.chip.label, runner.enabled, runner.paused, update.checking, update.checked_at, update.ready]);
55+ }
56+
57+ // One kind of job's, with the whole runner's.
58+ function kindStatus(kind, runner) {
59+ return JSON.stringify([runnerStatus(runner), kind.state, kind.enabled, kind.tone, kind.title, kind.cause]);
60+ }
61+
62+ // A fix's button. Choosing is done here, in this window; a copy says it
63+ // was copied. One that changes the runner (the catalogue says which:
64+ // `waits`) stays busy until the status that follows; one the catalogue
65+ // asks about first (`confirm`) asks.
66+ function actionButton(action, primary, key, status) {
4967 var button = document.createElement("button");
5068 button.type = "button";
5169 button.textContent = action.label;
5270 if (primary) button.className = "primary";
71+ key = key + ":" + action.id;
72+ if (busy.pressed(key, status)) busy.start(button);
5373 button.addEventListener("click", function () {
5474 if (action.id === "choose_workspace") return byId("runnerWorkspace").focus();
5575 if (action.id === "choose_backend") return byId("runnerBackend").focus();
56− if (action.id === "remove_sandbox" && !window.confirm(REMOVE_SANDBOX)) return;
57− var copying = action.id.indexOf("copy_") === 0;
58− // Busy until the status that follows draws the buttons again, so a
59− // press is seen to do something; given back if nothing comes.
60− if (!copying) working(button);
61− invoke("runner_control", { action: action.id }).then(
62− function () {
63− if (!copying) return;
64− button.textContent = "Copied";
65− setTimeout(function () {
66− button.textContent = action.label;
67− }, 2000);
68− },
69− function (error) {
70− done(button, action.label);
71− fail(error);
72− },
73− );
76+ if (action.confirm && !window.confirm(action.confirm)) return;
77+ busy.run(button, key, status, !!action.waits, function () {
78+ return invoke("runner_control", { action: action.id });
79+ }, fail).then(function (done) {
80+ if (!done || action.id.indexOf("copy_") !== 0) return;
81+ button.textContent = "Copied";
82+ setTimeout(function () {
83+ button.textContent = action.label;
84+ }, 2000);
85+ });
7486 });
7587 return button;
7688 }
77−
78− var REMOVE_SANDBOX =
79− "Remove this computer's sandbox? Its VM and registration go, and g1t makes its own in their place, registered with your workspace. Its old runners stay listed on g1t until you remove them there.";
8089
81− // A button at work: a spinner and its label, pressed no more until it is done.
82− function working(button) {
83− button.disabled = true;
84− button.classList.add("busy");
85− button.setAttribute("aria-busy", "true");
86− var label = button.textContent;
87− setTimeout(function () {
88− if (button.isConnected) done(button, label);
89− }, 20000);
90− }
91−
92− function done(button, label) {
93− button.disabled = false;
94− button.classList.remove("busy");
95− button.removeAttribute("aria-busy");
96− button.textContent = label;
97− }
98−
9990 function element(tag, className, text) {
10091 var item = document.createElement(tag);
10192 if (className) item.className = className;
133124 var ISOLATION = { "nested-container": "A fresh container in the VM", vm: "A VM of its own", container: "A container of its own", host: "None" };
134125 var BUSY = { busy: true, starting: true, downloading: true, updating: true };
135126 var cards = {};
136− var pending = {};
137127
138128 // A small arrow: the link goes out to g1t.
139129 function outward() {
249239 list.appendChild(row);
250240 }
251241
252− // Start or Stop for one kind of job, said for which, and what it is doing meanwhile.
253− function toggleButton(kind, onFirst) {
242+ // Start or Stop for one kind of job, said for which: busy from the click
243+ // until the status that follows says it started or stopped.
244+ function toggleButton(kind, onFirst, runner) {
254245 var on = kind.enabled;
255− var waiting = pending[kind.id];
256246 var button = element("button", on ? "quiet" : onFirst ? "primary" : null);
257247 button.type = "button";
258− button.textContent = waiting ? (on ? "Stopping…" : "Starting…") : on ? "Stop" : "Start";
248+ button.textContent = on ? "Stop" : "Start";
259249 button.setAttribute("aria-label", (on ? "Stop " : "Start ") + kind.label);
260− button.disabled = !!waiting;
250+ var key = "toggle:" + kind.id;
251+ var status = kindStatus(kind, runner);
252+ if (busy.pressed(key, status)) busy.start(button);
261253 button.addEventListener("click", function () {
262− pending[kind.id] = true;
263− renderKinds(lastKinds);
264− invoke("runner_control", { action: on ? "stop" : "start", capability: kind.id })
265− .catch(fail)
266− .then(function () {
267− // The status that follows says how it is; until then it says what was asked.
268− setTimeout(function () {
269− delete pending[kind.id];
270− renderKinds(lastKinds);
271− }, 1500);
272− });
254+ busy.run(button, key, status, true, function () {
255+ return invoke("runner_control", { action: on ? "stop" : "start", capability: kind.id });
256+ }, fail);
273257 });
274258 return button;
275259 }
289273 // What changes the buttons and the words is drawn again only when it changes,
290274 // so a button keeps its focus while the status ticks.
291275 var update = runner.diagnosis.update || null;
292− var signature = JSON.stringify([kind, !!pending[kind.id], runner.link, update && update.checked]);
276+ var signature = JSON.stringify([kind, runner.link, update && update.checked]);
293277 if (signature === card.signature) return;
294278 card.signature = signature;
295279
333317 // sees to it (waiting for the release's images), none does.
334318 var urgent = kind.tone === "problem" || kind.tone === "warning";
335319 kind.actions.forEach(function (action, at) {
336− actions.appendChild(actionButton(action, at === 0 && urgent && kind.state !== "waiting"));
320+ actions.appendChild(actionButton(action, at === 0 && urgent && kind.state !== "waiting", kind.id, kindStatus(kind, runner)));
337321 });
338322 if (kind.can_toggle) {
339− var toggle = toggleButton(kind, kind.actions.length === 0);
323+ var toggle = toggleButton(kind, kind.actions.length === 0, runner);
340324 toggle.classList.add("end");
341325 actions.appendChild(toggle);
342326 }
343327 actions.hidden = !actions.firstChild;
344328 }
345329
346− var lastKinds = null;
347−
348330 function renderKinds(runner) {
349− lastKinds = runner;
350331 var list = byId("runner-kinds");
351332 var kinds = (runner && runner.diagnosis.capabilities) || [];
352333 list.hidden = kinds.length === 0;
379360 var actions = byId("runner-actions");
380361 actions.textContent = "";
381362 diagnosis.actions.forEach(function (action, at) {
382− actions.appendChild(actionButton(action, at === 0));
363+ actions.appendChild(actionButton(action, at === 0, "runner", runnerStatus(runner)));
383364 });
384365 renderKinds(runner);
385366 byId("runner").checked = !!runner.enabled;
414395 check.hidden = !update;
415396 if (update) {
416397 check.textContent = update.check.label;
417− check.disabled = update.checking;
398+ // Busy while the app looks, whoever asked, and from a click until it says it is looking.
399+ if (update.checking || busy.pressed("check", runnerStatus(runner))) busy.start(check);
400+ else busy.stop(check);
418401 }
402+ // A Start or Stop pressed is done once the status says so.
403+ if (!busy.pressed("engine", runnerStatus(runner))) busy.stop(byId("engine-toggle"));
419404 byId("runner-view").hidden = !runner.link;
420405 // The log follows along, unless it was scrolled back to read.
421406 var log = byId("runner-log");
426411
427412 byId("runnerBackend").addEventListener("change", save);
428413 byId("runnerWorkspace").addEventListener("change", save);
429− byId("runner-view").addEventListener("click", function () {
430− invoke("runner_control", { action: "view_runners" }).catch(fail);
431− });
432− byId("runner-open-log").addEventListener("click", function () {
433− invoke("runner_control", { action: "view_log" }).catch(fail);
434− });
435− // Looks now; what it finds installs as each kind of job is idle, and the status that follows says so.
436− byId("runner-check").addEventListener("click", function () {
437− byId("runner-check").disabled = true;
438− invoke("runner_control", { action: "check_updates" }).catch(function (error) {
439− byId("runner-check").disabled = false;
440− fail(error);
414+ // A button that asks the runner for something: busy until the status
415+ // that follows when it `waits`, or until the app has it.
416+ function control(id, key, action, waits) {
417+ var button = byId(id);
418+ button.addEventListener("click", function () {
419+ var status = current && current.runnerView ? runnerStatus(current.runnerView) : "";
420+ busy.run(button, key, status, waits, function () {
421+ return invoke("runner_control", { action: typeof action === "function" ? action() : action });
422+ }, fail);
441423 });
442− });
424+ }
425+ control("runner-view", "view", "view_runners", false);
426+ control("runner-open-log", "log", "view_log", false);
427+ // Looks now; what it finds installs as each kind of job is idle, and the status that follows says so.
428+ control("runner-check", "check", "check_updates", true);
443429
444430 // ── The whole runner, at the top ─────────────────────────────────────
445431 // As the title bar's chip says it, with what it is doing in a line and
458444 toggle.dataset.action = on ? "stop" : "start";
459445 }
460446
461− byId("engine-toggle").addEventListener("click", function () {
462− var toggle = byId("engine-toggle");
463− toggle.disabled = true;
464− invoke("runner_control", { action: toggle.dataset.action })
465− .catch(fail)
466− .then(function () {
467− toggle.disabled = false;
468− });
469− });
447+ control("engine-toggle", "engine", function () {
448+ return byId("engine-toggle").dataset.action;
449+ }, true);
470450
471451 // Every setting, as the app has it now (the settings window changes the
472452 // rest), with the runner's own as they are on the page.
501481 events
502482 .listen("g1t-runner-status", function (event) {
503483 if (current) current.runnerView = event.payload;
504− pending = {};
505484 renderRunner(event.payload);
506485 })
507486 .catch(function () {});
+1−0
129129 <p class="muted small foot"><a href="https://docs.g1t.sh/guides/desktop/">About the desktop app</a></p>
130130 </main>
131131 </div>
132+ <script src="busy.js"></script>
132133 <script src="settings.js"></script>
133134 </body>
134135 </html>
+17−10
11 // The settings window's work: show what the app has, and keep each change
22 // the moment it is made. Everything it asks is a command in
3−// src/bridge.rs, answered only to this window. The runner has a window of
4−// its own (runner.html); here it is a line that opens it.
3+// src/bridge.rs. The settings usually open over the site, which draws
4+// them (apps/web components/desktop-settings.tsx); this window is for when
5+// the window shows no page of the site's. The runner has a window of its
6+// own (runner.html); here it is a line that opens it. A button that asks
7+// the app for something is busy until it is done (busy.js).
58 (function () {
69 var SWITCHES = ["startHidden", "closeToTray", "notifications", "flash", "badge", "systemTitleBar", "autoUpdate"];
710 // The settings the app keeps (src/settings.rs Settings); a save sends them all.
98101 }
99102
100103 byId("open-runner").addEventListener("click", function () {
101− invoke("runner_control", { action: "open_runner" }).catch(fail);
104+ window.g1tBusy.run(byId("open-runner"), "open-runner", "", false, function () {
105+ return invoke("runner_control", { action: "open_runner" });
106+ }, fail);
102107 });
103108
104109 SWITCHES.forEach(function (key) {
143148 checking = true;
144149 var button = byId("check");
145150 var status = byId("update-status");
146− button.disabled = true;
151+ window.g1tBusy.start(button);
147152 status.textContent = "Checking, and downloading any new version…";
148153 invoke("check_for_updates")
149154 .then(function (answer) {
158163 })
159164 .then(function () {
160165 checking = false;
161− button.disabled = false;
166+ window.g1tBusy.stop(button);
162167 });
163168 }
164169
165170 byId("check").addEventListener("click", check);
166171 byId("restart").addEventListener("click", function () {
167− byId("restart").disabled = true;
172+ window.g1tBusy.start(byId("restart"));
168173 byId("update-status").textContent = "Installing…";
169174 invoke("restart_to_update").catch(function (error) {
170− byId("restart").disabled = false;
175+ window.g1tBusy.stop(byId("restart"));
171176 fail(error);
172177 });
173178 });
191196 }
192197
193198 byId("restart-app").addEventListener("click", function () {
194− byId("restart-app").disabled = true;
199+ window.g1tBusy.start(byId("restart-app"));
195200 invoke("restart").catch(function (error) {
196− byId("restart-app").disabled = false;
201+ window.g1tBusy.stop(byId("restart-app"));
197202 fail(error);
198203 });
199204 });
200205
201206 byId("change-server").addEventListener("click", function () {
202− invoke("change_server").catch(fail);
207+ window.g1tBusy.run(byId("change-server"), "change-server", "", false, function () {
208+ return invoke("change_server");
209+ }, fail);
203210 });
204211
205212 // Links out, in the system's browser (src/bridge.js does the same on the site).
+19−11
113113 outline: 2px solid var(--accent);
114114 outline-offset: -2px;
115115 }
116−/* The runner's chip: a dot in its tone and what it is doing. */
116+/* The runner: a dot in its tone, right beside the settings, the size of
117+ an icon button. A wide window has room for what it is doing too. Its
118+ menu hangs under it, drawn by the page below (the site). */
117119 .chip {
118− gap: 6px;
119− height: 24px;
120− margin-right: 4px;
121− padding: 0 9px;
120+ gap: 7px;
121+ min-width: 28px;
122+ height: 26px;
123+ padding: 0 10px;
122124 border-radius: 6px;
123125 white-space: nowrap;
124126 }
125127 .chip[hidden] {
126128 display: none;
127129 }
128−.chip:hover {
130+.chip:hover,
131+.chip[aria-expanded="true"] {
129132 background: var(--hover);
130133 color: var(--fg);
131134 }
132135 .chip-dot {
133− width: 7px;
134− height: 7px;
136+ width: 8px;
137+ height: 8px;
138+ flex: none;
135139 border-radius: 50%;
136140 background: var(--muted);
137141 }
162166 animation: none;
163167 }
164168 }
165−/* A narrow window keeps the dot alone. */
166−@media (max-width: 760px) {
169+/* The dot alone, until the window is wide enough that the words fit
170+ beside it without crowding the search. */
171+.chip-text {
172+ display: none;
173+}
174+@media (min-width: 1360px) {
167175 .chip-text {
168− display: none;
176+ display: inline;
169177 }
170178 }
+16−7
7979 });
8080 left.append(back, forward);
8181
82− // The runner, in a few words beside the settings, as Docker Desktop shows
83− // its engine: what it is doing, live, and a click opens its window
84− // (runner.html). The words are the app's (diagnosis.rs chip).
85− var chip = button("chip", "Runner", null, function () {
86− act("runner");
82+ // The runner, as a dot right beside the settings: its colour says what
83+ // it is doing, live, and a wide window says it in words too. A click
84+ // opens its menu, which the site draws under the dot (or, with no page
85+ // of the site's to draw it, the Runner window). The words are the app's
86+ // (diagnosis.rs chip).
87+ var chip = button("chip", "Runner", null, function (b) {
88+ var box = b.getBoundingClientRect();
89+ var width = document.documentElement.clientWidth || window.innerWidth || 1;
90+ // Where the dot's right edge is, as a share of the bar, so the menu
91+ // lines up under it whatever the site's zoom.
92+ quietly(invoke("title_bar_act", { action: "runner", at: box.right / width }));
8793 });
8894 chip.hidden = true;
95+ chip.setAttribute("aria-haspopup", "dialog");
96+ chip.setAttribute("aria-expanded", "false");
8997 var chipDot = document.createElement("span");
9098 chipDot.className = "chip-dot";
9199 var chipText = document.createElement("span");
96104 chip.hidden = false;
97105 chip.dataset.tone = said.tone;
98106 chipText.textContent = said.label;
99− chip.setAttribute("aria-label", said.label + ": open the runner");
100− chip.title = "Open the runner";
107+ chip.setAttribute("aria-label", /runner/i.test(said.label) ? said.label : "Runner: " + said.label);
108+ chip.title = said.label;
101109 }
102110 // The app's settings, at the right of the bar, before the window's
103111 // buttons: where they are found without the menu.
188196 title.textContent = page.title || "g1t";
189197 middle.append(title);
190198 }
199+ chip.setAttribute("aria-expanded", page.runnerMenu ? "true" : "false");
191200 back.disabled = page.canGoBack === false;
192201 forward.disabled = page.canGoForward === false;
193202 setColors(page.colors);
+1−0
2828 "runner_offer",
2929 "runner_register",
3030 "runner_control",
31+ "site_ready",
3132 ])))
3233 .expect("the app's build could not be prepared");
3334 }
+57−4
106106 checkForUpdates: function () {
107107 return invoke("check_for_updates");
108108 },
109− // The app's own settings, in a window of their own.
109+ // The app's own settings: the dialog this page draws (`settings.onOpen`),
110+ // or their own window when the page can't.
110111 openSettings: function () {
111112 quietly(invoke("open_settings"));
112113 },
114+ // The app's settings, for the dialog the site draws over itself, and
115+ // the runner's menu under the title bar's dot (src/settings.rs). The
116+ // page says it draws them with `ready`; until then they open in windows
117+ // of their own.
118+ settings: Object.freeze({
119+ ready: function (ready) {
120+ return invoke("site_ready", { ready: ready !== false });
121+ },
122+ get: function () {
123+ return invoke("get_settings");
124+ },
125+ // Every setting but opening at sign-in, as `get` has them (camelCase).
126+ save: function (settings) {
127+ return invoke("save_settings", { settings: settings });
128+ },
129+ setOpenAtLogin: function (on) {
130+ return invoke("set_open_at_login", { on: !!on });
131+ },
132+ // "current", "ready:<version>", or rejects with why; the dialog says it, so no notification.
133+ checkForUpdates: function () {
134+ return invoke("check_for_updates", { shown: true });
135+ },
136+ restart: function () {
137+ return invoke("restart");
138+ },
139+ restartToUpdate: function () {
140+ return invoke("restart_to_update");
141+ },
142+ changeServer: function () {
143+ return invoke("change_server");
144+ },
145+ // Open the dialog: { section: "runner" | "updates" | null, check: boolean }.
146+ onOpen: function (listener) {
147+ return on("g1t-open-settings", listener);
148+ },
149+ // The runner menu: { action: "toggle" | "open", at: share of the bar's width | null }.
150+ onRunnerMenu: function (listener) {
151+ return on("g1t-runner-menu", listener);
152+ },
153+ // An update finished downloading, with its version.
154+ onUpdateReady: function (listener) {
155+ return on("g1t-update-ready", listener);
156+ },
157+ // Whether the runner menu is open, which the title bar's dot shows.
158+ setRunnerMenuOpen: function (open) {
159+ runnerMenu = !!open;
160+ report();
161+ },
162+ }),
113163 // The title bar's search, in its middle: the site hands it a label
114164 // ("Search Flagon") and what opens its search. Returns what takes it away.
115165 setSearch: function (label, open) {
224274 // ── The title bar ─────────────────────────────────────────────────────
225275 // What the bar above the page shows of it: the title, the search the
226276 // site handed over (`setSearch`), whether there is a page to go back or
227− // forward to, and the page's colours, so the bar is the colour of g1t's
228− // rail, light or dark, or of whatever page is showing. Sent whenever any
229− // of it changes; the bar's search comes back as an event.
277+ // forward to, the page's colours, so the bar is the colour of g1t's
278+ // rail, light or dark, or of whatever page is showing, and whether the
279+ // runner menu is open under the bar's dot. Sent whenever any of it
280+ // changes; the bar's search comes back as an event.
230281 var search = null;
282+ var runnerMenu = false;
231283 var report = function () {};
232284 if (FRAME !== "custom") return;
233285
285337 canGoBack: nav && typeof nav.canGoBack === "boolean" ? nav.canGoBack : null,
286338 canGoForward: nav && typeof nav.canGoForward === "boolean" ? nav.canGoForward : null,
287339 colors: colors(),
340+ runnerMenu: runnerMenu,
288341 };
289342 var text = JSON.stringify(page);
290343 if (text === sent) return;
+67−16
4747 if config::is_server(app, &url) { Ok(()) } else { Err("only g1t's own pages may ask that".into()) }
4848 }
4949
50+/// The page asking is the server's, in the main window under the title
51+/// bar: the one that draws the settings dialog and the runner menu.
52+fn from_site_page(app: &AppHandle, webview: &Webview) -> Result<(), String> {
53+ from_site(app, webview)?;
54+ if webview.label() == frame::SITE { Ok(()) } else { Err("only g1t's page in the window may ask that".into()) }
55+}
56+
5057 /// The page asking is the launcher.
5158 fn from_launcher(webview: &Webview) -> Result<(), String> {
5259 let url = caller(webview)?;
163170 }
164171
165172 /// Looks for an update now: `current`, `ready:<version>`, or an error.
166−/// The settings window shows the answer itself; anyone else asking is also
167−/// told with a notification.
173+/// The settings, in their window or the dialog over the site (`shown`),
174+/// show the answer themselves; anyone else asking is also told with a
175+/// notification.
168176 #[tauri::command]
169−pub async fn check_for_updates(app: AppHandle, webview: Webview) -> Result<String, String> {
177+pub async fn check_for_updates(app: AppHandle, webview: Webview, shown: Option<bool>) -> Result<String, String> {
170178 from_either(&app, &webview)?;
171− updates::check(&app, webview.label() != settings::SETTINGS).await
179+ let shown = webview.label() == settings::SETTINGS || shown == Some(true);
180+ updates::check(&app, !shown).await
172181 }
173182
174183 /// A title bar button: `minimize`, `maximize` (which toggles), `close`,
202211 Ok(frame::current(&app))
203212 }
204213
205−/// A title bar button that acts on the page: `back`, `forward`, `search`, `focus`.
214+/// A title bar button that acts on the page: `back`, `forward`, `search`,
215+/// `focus`, `settings`, or `runner`, with `at` where its dot is across the
216+/// bar, as a share of the bar's width.
206217 #[tauri::command]
207−pub fn title_bar_act(app: AppHandle, webview: Webview, action: String) -> Result<(), String> {
218+pub fn title_bar_act(app: AppHandle, webview: Webview, action: String, at: Option<f64>) -> Result<(), String> {
208219 from_bar(&webview)?;
209− frame::act(&app, &action)
220+ frame::act(&app, &action, at)
210221 }
211222
212223 /// Restarts g1t, installing a downloaded update on the way: the settings
213224 /// window offers it after a change that needs one.
214225 #[tauri::command]
215226 pub fn restart(app: AppHandle, webview: Webview) -> Result<(), String> {
216− from_settings(&webview)?;
227+ from_settings(&webview).or_else(|_| from_site_page(&app, &webview))?;
217228 if updates::ready(&app).is_some() {
218229 updates::restart(&app);
219230 }
224235 /// The settings window's "Restart now": installs the downloaded update and starts it.
225236 #[tauri::command]
226237 pub fn restart_to_update(app: AppHandle, webview: Webview) -> Result<(), String> {
227− from_settings(&webview)?;
238+ from_settings(&webview).or_else(|_| from_site_page(&app, &webview))?;
228239 updates::restart(&app);
229240 Ok(())
230241 }
231242
232−/// Opens the app's settings window: the site's account menu asks for it.
243+/// Opens the app's settings: the site's account menu asks for it. They
244+/// open over the site, or in their own window when it can't draw them.
233245 #[tauri::command]
234246 pub fn open_settings(app: AppHandle, webview: Webview) -> Result<(), String> {
235247 from_either(&app, &webview)?;
237249 Ok(())
238250 }
239251
240−/// What the settings window shows.
252+/// The site's page says it draws the settings dialog and the runner menu
253+/// itself, once it can (true), or no longer (false). Until it says so, each
254+/// page loaded in the window has them open in windows of their own.
241255 #[tauri::command]
256+pub fn site_ready(app: AppHandle, webview: Webview, ready: bool) -> Result<(), String> {
257+ from_site_page(&app, &webview)?;
258+ config::set_site_ready(&app, ready);
259+ Ok(())
260+}
261+
262+/// What the settings show.
263+#[tauri::command]
242264 pub fn get_settings(app: AppHandle, webview: Webview) -> Result<settings::View, String> {
243− from_settings_or_runner(&webview)?;
265+ from_settings_or_runner(&webview).or_else(|_| from_site_page(&app, &webview))?;
244266 Ok(settings::view(&app))
245267 }
246268
247−/// The settings window changed something: everything but opening at sign-in.
269+/// The settings changed something: everything but opening at sign-in.
270+///
271+/// The site's page may change all of it but one thing: it can't take the
272+/// runner out of its sandbox. Jobs that run in Docker or directly on the
273+/// computer can reach the person's files, so that choice is made only in
274+/// the Runner window, a page bundled with the app, and never by a page
275+/// from the network, whatever it loads.
248276 #[tauri::command]
249277 pub fn save_settings(app: AppHandle, webview: Webview, settings: Settings) -> Result<settings::View, String> {
250− from_settings_or_runner(&webview)?;
278+ if from_settings_or_runner(&webview).is_err() {
279+ from_site_page(&app, &webview)?;
280+ if !site_may_choose(config::settings(&app).runner_backend, settings.runner_backend) {
281+ return Err("How jobs run outside the sandbox is chosen in the Runner window.".into());
282+ }
283+ }
251284 settings::apply(&app, settings)?;
252285 Ok(settings::view(&app))
253286 }
254287
288+/// Whether the site's page may change how jobs run from `before` to
289+/// `after`: keeping it, or putting it back in the sandbox.
290+fn site_may_choose(before: runner::Backend, after: runner::Backend) -> bool {
291+ after == before || after == runner::Backend::Sandboxed
292+}
293+
255294 /// Opening at sign-in, which the system keeps.
256295 #[tauri::command]
257296 pub fn set_open_at_login(app: AppHandle, webview: Webview, on: bool) -> Result<settings::View, String> {
258− from_settings(&webview)?;
297+ from_settings(&webview).or_else(|_| from_site_page(&app, &webview))?;
259298 settings::open_at_login(&app, on)?;
260299 Ok(settings::view(&app))
261300 }
263302 /// The settings window's "Change…": the main window goes to the launcher's choice of server.
264303 #[tauri::command]
265304 pub fn change_server(app: AppHandle, webview: Webview) -> Result<(), String> {
266− from_settings(&webview)?;
305+ from_settings(&webview).or_else(|_| from_site_page(&app, &webview))?;
267306 if let Some(window) = app.get_webview_window(settings::SETTINGS) {
268307 let _ = window.close();
269308 }
320359 assert!(super::script(false).contains(r#"var FRAME = "system";"#));
321360 assert!(script.contains(r#"var OUTSIDE = ["/pricing""#));
322361 }
362+
363+ #[test]
364+ fn the_site_cannot_take_the_runner_out_of_its_sandbox() {
365+ use crate::runner::Backend::{Docker, Host, Sandboxed};
366+ assert!(super::site_may_choose(Sandboxed, Sandboxed));
367+ assert!(super::site_may_choose(Host, Host));
368+ assert!(super::site_may_choose(Docker, Sandboxed));
369+ assert!(super::site_may_choose(Host, Sandboxed));
370+ assert!(!super::site_may_choose(Sandboxed, Host));
371+ assert!(!super::site_may_choose(Sandboxed, Docker));
372+ assert!(!super::site_may_choose(Docker, Host));
373+ }
323374 }
+29−3
3838 pub waiting: Mutex<i64>,
3939 /// The app's own settings, as the settings window last left them.
4040 pub settings: Mutex<Settings>,
41+ /// The site's page in the window draws the settings and the runner
42+ /// menu itself: it said so once it could, and each page that starts
43+ /// loading forgets it until it says so again (`site_ready`).
44+ pub site_ready: Mutex<bool>,
4145 }
4246
4347 use crate::settings::Settings;
170174 "allow-runner-offer",
171175 "allow-runner-register",
172176 "allow-runner-control",
177+ // The settings dialog and the runner menu, which the site draws over itself.
178+ "allow-site-ready",
179+ "allow-get-settings",
180+ "allow-save-settings",
181+ "allow-set-open-at-login",
182+ "allow-restart",
183+ "allow-restart-to-update",
184+ "allow-change-server",
173185 ];
174186
175187 /// What GitHub's pages may call while someone signs in: telling the title bar their title.
202214 Ok(())
203215 }
204216
205−/// The window started loading a page.
206−pub fn at(app: &AppHandle, url: Url) {
207− *app.state::<State>().at.lock().unwrap() = Some(url);
217+/// The window started loading a page (`started`), or finished. A page
218+/// that starts loading hasn't said it can draw the settings yet.
219+pub fn at(app: &AppHandle, url: Url, started: bool) {
220+ let state = app.state::<State>();
221+ *state.at.lock().unwrap() = Some(url);
222+ if started {
223+ *state.site_ready.lock().unwrap() = false;
224+ }
225+}
226+
227+/// The site's page says whether it draws the settings and the runner menu.
228+pub fn set_site_ready(app: &AppHandle, ready: bool) {
229+ *app.state::<State>().site_ready.lock().unwrap() = ready;
230+}
231+
232+pub fn site_ready(app: &AppHandle) -> bool {
233+ *app.state::<State>().site_ready.lock().unwrap()
208234 }
209235
210236 /// Whether the window is on one of the server's pages now.
+11−6
223223 pub can_go_back: Option<bool>,
224224 pub can_go_forward: Option<bool>,
225225 pub colors: Option<Colors>,
226+ /// The runner menu the site draws under the bar's dot is open.
227+ #[serde(default)]
228+ pub runner_menu: bool,
226229 }
227230
228231 /// The page as last reported, kept for a bar that loads after it.
255258 }
256259
257260 /// A button on the bar that acts on the page: `back`, `forward`, `search`,
258−/// or `focus`, which hands the keyboard back to the page after a click on
259−/// the bar.
260−pub fn act(app: &AppHandle, action: &str) -> Result<(), String> {
261+/// `settings`, `runner` (the dot beside the settings, `at` where it is
262+/// across the bar), or `focus`, which hands the keyboard back to the page
263+/// after a click on the bar.
264+pub fn act(app: &AppHandle, action: &str, at: Option<f64>) -> Result<(), String> {
261265 let site = site(app).ok_or("there is no page")?;
262266 match action {
263267 "back" => site.eval("history.back()").map_err(|error| error.to_string())?,
264268 "forward" => site.eval("history.forward()").map_err(|error| error.to_string())?,
265269 "search" => app.emit_to(EventTarget::webview(SITE), SEARCH_EVENT, ()).map_err(|error| error.to_string())?,
266− // The app's settings, a click away rather than only in its menu.
270+ // The app's settings, a click away rather than only in its menu:
271+ // a dialog the site draws over itself.
267272 "settings" => {
268273 crate::settings::open(app);
269274 return Ok(());
270275 }
271− // The runner on its own: the chip beside the settings button.
276+ // The runner's menu, hanging under its dot beside the settings button.
272277 "runner" => {
273− crate::settings::open_runner(app);
278+ crate::settings::runner_menu(app, true, at);
274279 return Ok(());
275280 }
276281 "focus" => {}
+3−2
2525 mod tray;
2626 mod updates;
2727
28−use tauri::webview::WebviewBuilder;
28+use tauri::webview::{PageLoadEvent, WebviewBuilder};
2929 use tauri::{RunEvent, WebviewUrl, WindowEvent};
3030 use tauri_plugin_window_state::{StateFlags, WindowExt};
3131
8787 bridge::runner_offer,
8888 bridge::runner_register,
8989 bridge::runner_control,
90+ bridge::site_ready,
9091 ])
9192 .on_menu_event(|app, event| menu::handle(app, event.id().as_ref()))
9293 .setup(|app| {
106107 })
107108 .on_page_load({
108109 let handle = handle.clone();
109− move |_webview, payload| config::at(&handle, payload.url().clone())
110+ move |_webview, payload| config::at(&handle, payload.url().clone(), matches!(payload.event(), PageLoadEvent::Started))
110111 });
111112 let builder = frame::main_window(app, custom)?;
112113 // macOS has one menu bar, the app's; elsewhere the menu is the window's own.
+2−2
140140 match id {
141141 "open" => tray::show(app),
142142 "settings" => settings::open(app),
143− "runner-status" => settings::open_runner(app),
143+ "runner-status" => settings::runner_menu(app, false, None),
144144 "runner-toggle" => crate::runner::toggle(app),
145145 "server" => tray::launcher(app, "change"),
146− // The settings window asks, and shows the answer where it cannot be missed.
146+ // The settings ask, and show the answer where it cannot be missed.
147147 "check" => settings::open_and_check(app),
148148 "restart" => updates::restart(app),
149149 "quit" => updates::quit(app),
+64−22
9494 pub struct Action {
9595 pub id: &'static str,
9696 pub label: &'static str,
97+ /// Its button stays busy, after a click, until the runner's status
98+ /// that follows changes (or an error, or a while with no change): the
99+ /// runner starting, stopping, retrying or looking for updates. One
100+ /// that opens a page, a window or copies is done once the app has it.
101+ pub waits: bool,
102+ /// What the person is asked before it goes ahead, for a fix that can't be undone.
103+ #[serde(skip_serializing_if = "Option::is_none")]
104+ pub confirm: Option<&'static str>,
97105 }
98106
107+impl Action {
108+ /// A fix that is done the moment the app has it.
109+ const fn now(id: &'static str, label: &'static str) -> Self {
110+ Self { id, label, waits: false, confirm: None }
111+ }
112+
113+ /// A fix whose effect the runner's status shows when it comes.
114+ const fn waits(id: &'static str, label: &'static str) -> Self {
115+ Self { id, label, waits: true, confirm: None }
116+ }
117+
118+ const fn confirming(self, question: &'static str) -> Self {
119+ Self { confirm: Some(question), ..self }
120+ }
121+}
122+
99123 #[derive(Clone, Debug, PartialEq, Eq, Serialize)]
100124 pub struct Diagnosis {
101125 pub code: Code,
307331 }
308332 }
309333
310−const START: Action = Action { id: "start", label: "Start" };
311−const TURN_ON: Action = Action { id: "start", label: "Turn on" };
312−const RETRY: Action = Action { id: "retry", label: "Retry" };
313−const CHECK_NOW: Action = Action { id: "retry", label: "Check now" };
334+const START: Action = Action::waits("start", "Start");
335+const TURN_ON: Action = Action::waits("start", "Turn on");
336+const RETRY: Action = Action::waits("retry", "Retry");
337+const CHECK_NOW: Action = Action::waits("retry", "Check now");
314338 /// Looks for a newer runner now, and installs it as soon as its kinds of job are idle.
315−const CHECK_UPDATES: Action = Action { id: "check_updates", label: "Check for runner updates" };
316−const CHECK_AGAIN: Action = Action { id: "check_updates", label: "Check again" };
339+const CHECK_UPDATES: Action = Action::waits("check_updates", "Check for runner updates");
340+const CHECK_AGAIN: Action = Action::waits("check_updates", "Check again");
317341 /// Removes the sandbox this computer has (`sandbox down --delete`, in the
318342 /// app's own folder for it) and starts the app's own: what the command a
319343 /// person would type does, without needing to know where that folder is.
320−const REMOVE_SANDBOX: Action = Action { id: "remove_sandbox", label: "Remove it and start again" };
321−const RESTART: Action = Action { id: "restart", label: "Restart" };
322−const SIGN_IN: Action = Action { id: "sign_in", label: "Sign in" };
323−const CHOOSE_WORKSPACE: Action = Action { id: "choose_workspace", label: "Choose workspace" };
324−const NEW_WORKSPACE: Action = Action { id: "new_workspace", label: "Create a workspace" };
325−const CHOOSE_BACKEND: Action = Action { id: "choose_backend", label: "Choose how jobs run" };
326−const OPEN_DOCKER: Action = Action { id: "open_docker", label: "Open Docker" };
327−const GET_DOCKER: Action = Action { id: "get_docker", label: "Get Docker" };
328−const INSTALL_SYSBOX: Action = Action { id: "install_sysbox", label: "Install Sysbox" };
329−const COPY_WSL_INSTALL: Action = Action { id: "copy_wsl_install", label: "Copy command" };
330−const COPY_VFKIT_INSTALL: Action = Action { id: "copy_vfkit_install", label: "Copy command" };
331−const WINDOWS_FEATURES: Action = Action { id: "windows_features", label: "Open Windows features" };
332−const LEARN_MORE: Action = Action { id: "learn_more", label: "Learn more" };
333−const VIEW_LOG: Action = Action { id: "view_log", label: "View log" };
334−const REPORT: Action = Action { id: "report", label: "Report" };
335−const VIEW_RUNNERS: Action = Action { id: "view_runners", label: "View on g1t" };
344+const REMOVE_SANDBOX: Action = Action::waits("remove_sandbox", "Remove it and start again").confirming(
345+ "Remove this computer's sandbox? Its VM and registration go, and g1t makes its own in their place, registered with your workspace. Its old runners stay listed on g1t until you remove them there.",
346+);
347+const RESTART: Action = Action::waits("restart", "Restart");
348+const SIGN_IN: Action = Action::now("sign_in", "Sign in");
349+const CHOOSE_WORKSPACE: Action = Action::now("choose_workspace", "Choose workspace");
350+const NEW_WORKSPACE: Action = Action::now("new_workspace", "Create a workspace");
351+const CHOOSE_BACKEND: Action = Action::now("choose_backend", "Choose how jobs run");
352+/// Docker takes a while to start; the runner's status says when it has.
353+const OPEN_DOCKER: Action = Action::waits("open_docker", "Open Docker");
354+const GET_DOCKER: Action = Action::now("get_docker", "Get Docker");
355+const INSTALL_SYSBOX: Action = Action::now("install_sysbox", "Install Sysbox");
356+const COPY_WSL_INSTALL: Action = Action::now("copy_wsl_install", "Copy command");
357+const COPY_VFKIT_INSTALL: Action = Action::now("copy_vfkit_install", "Copy command");
358+const WINDOWS_FEATURES: Action = Action::now("windows_features", "Open Windows features");
359+const LEARN_MORE: Action = Action::now("learn_more", "Learn more");
360+const VIEW_LOG: Action = Action::now("view_log", "View log");
361+const REPORT: Action = Action::now("report", "Report");
362+const VIEW_RUNNERS: Action = Action::now("view_runners", "View on g1t");
336363
337364 fn some(text: impl Into<String>) -> Option<String> {
338365 Some(text.into())
897924 }
898925
899926 #[test]
927+ fn a_fix_that_changes_the_runner_waits_for_its_status() {
928+ for action in [START, TURN_ON, RETRY, CHECK_NOW, CHECK_UPDATES, CHECK_AGAIN, REMOVE_SANDBOX, RESTART, OPEN_DOCKER] {
929+ assert!(action.waits, "{} waits for the status that follows", action.id);
930+ }
931+ for action in [SIGN_IN, CHOOSE_WORKSPACE, CHOOSE_BACKEND, GET_DOCKER, COPY_WSL_INSTALL, LEARN_MORE, VIEW_LOG, VIEW_RUNNERS] {
932+ assert!(!action.waits, "{} is done once the app has it", action.id);
933+ }
934+ // Only what can't be undone asks first.
935+ assert!(REMOVE_SANDBOX.confirm.is_some_and(|question| question.ends_with('.') && question.contains('?')));
936+ assert!(RETRY.confirm.is_none());
937+ let said = serde_json::to_value(RETRY).unwrap();
938+ assert_eq!(said, serde_json::json!({ "id": "retry", "label": "Retry", "waits": true }));
939+ }
940+
941+ #[test]
900942 fn every_failure_says_why_and_how_to_fix_it() {
901943 for code in [
902944 Code::SignedOut,
+6−2
582582 Some(workspace) => crate::links::go(app, &format!("/{workspace}/-/runners")),
583583 None => crate::links::go(app, "/"),
584584 },
585− // They are chosen in the Runner window.
586− "choose_workspace" | "choose_backend" | "open_runner" => settings::open_runner(app),
585+ // They are chosen in the settings' Runner section.
586+ "choose_workspace" | "choose_backend" => settings::open_runner_settings(app),
587+ "open_runner" => settings::runner_menu(app, false, None),
588+ // The Runner window, bundled with the app: where jobs are taken out
589+ // of the sandbox, which no page from the network may do (bridge.rs).
590+ "runner_window" => settings::open_runner_window(app),
587591 "view_log" => {
588592 let path = log_file(app);
589593 if !path.exists() {
+84−16
11 //! The app's own settings: how it starts, what closing the window does,
22 //! how it tells the person something arrived, its title bar, zoom,
3−//! updates, and the runner. They live in their own window (`launcher/settings.html`),
4−//! opened from the menu, the tray, or the site's account menu, and are
5−//! kept with the server in `desktop.json` (config.rs). Whether g1t opens
6−//! when someone signs in to their computer is kept by the system itself,
7−//! through the autostart plugin, and read back from it.
3+//! updates, and the runner. They open as a dialog over the site, which the
4+//! site's page draws (apps/web components/desktop-settings.tsx), from the
5+//! title bar, the menu, the tray, or the site's account menu. While the
6+//! window shows no page of the site's (the launcher, the offline page,
7+//! signing in), they open in a window of their own instead
8+//! (`launcher/settings.html`). They are kept with the server in
9+//! `desktop.json` (config.rs). Whether g1t opens when someone signs in to
10+//! their computer is kept by the system itself, through the autostart
11+//! plugin, and read back from it.
812
913 use serde::{Deserialize, Serialize};
10−use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
14+use tauri::{AppHandle, Emitter, EventTarget, Manager, WebviewUrl, WebviewWindowBuilder};
1115 use tauri_plugin_autostart::ManagerExt;
1216
1317 use crate::config;
1418
15−/// The settings window's label.
19+/// The settings window's label: the settings when the site can't draw them.
1620 pub const SETTINGS: &str = "settings";
17−/// The Runner window's label: the runner on its own, apart from the
18−/// settings, opened from the title bar's chip, the tray or the menu
19−/// (launcher/runner.html).
21+/// The Runner window's label: the runner on its own (launcher/runner.html),
22+/// when the site can't draw its menu under the title bar's dot.
2023 pub const RUNNER: &str = "runner";
2124
2225 /// The argument the system starts g1t with at sign-in, so it can tell a
209212 pub const CHECK_EVENT: &str = "g1t-check-updates";
210213 /// What it listens for: an update is downloaded, with its version.
211214 pub const READY_EVENT: &str = "g1t-update-ready";
215+/// What the site's page listens for: open the settings dialog (`Opening`).
216+pub const OPEN_EVENT: &str = "g1t-open-settings";
217+/// What the site's page listens for: the runner menu under the title
218+/// bar's dot (`RunnerMenu`).
219+pub const RUNNER_MENU_EVENT: &str = "g1t-runner-menu";
220+
221+/// The settings dialog, opened at a section (`runner`, `updates`), and
222+/// looking for updates as it opens when `check` is set.
223+#[derive(Clone, Debug, Serialize)]
224+pub struct Opening {
225+ pub section: Option<&'static str>,
226+ pub check: bool,
227+}
228+
229+/// The runner menu: `toggle`, from the dot, or `open`. `at` is where the
230+/// dot is across the bar, as a share of its width, so the menu hangs under
231+/// it whatever the site's zoom.
232+#[derive(Clone, Debug, Serialize)]
233+pub struct RunnerMenu {
234+ pub action: &'static str,
235+ pub at: Option<f64>,
236+}
237+
238+/// Whether the site's page can draw the settings and the runner menu
239+/// itself: the window is on one of the server's pages, and that page said
240+/// it can (`site_ready` in bridge.rs). A page of an older site never says
241+/// so, and they open in windows of their own, as they did.
242+fn in_site(app: &AppHandle) -> bool {
243+ config::on_site(app) && config::site_ready(app)
244+}
212245
213−/// Opens the settings window, or brings it forward.
246+/// Opens the settings, or brings them forward.
214247 pub fn open(app: &AppHandle) {
215− show(app, false);
248+ open_at(app, None, false);
216249 }
217250
218−/// "Check for updates…" in a menu: the settings window opens and checks,
219−/// and says what it found, the download included, in its Updates section.
251+/// "Check for updates…" in a menu: the settings open at Updates and check,
252+/// and say what they found, the download included.
220253 pub fn open_and_check(app: &AppHandle) {
221− show(app, true);
254+ open_at(app, Some("updates"), true);
255+}
256+
257+/// The settings at their Runner section: how jobs run, the workspace, each
258+/// kind of job. A fix that asks the person to choose one opens them there.
259+pub fn open_runner_settings(app: &AppHandle) {
260+ if in_site(app) {
261+ open_at(app, Some("runner"), false);
262+ } else {
263+ open_runner_window(app);
264+ }
265+}
266+
267+/// The settings over the site, the main window shown if it was hidden; in
268+/// a window of their own while the site isn't there to draw them.
269+fn open_at(app: &AppHandle, section: Option<&'static str>, check: bool) {
270+ if in_site(app) {
271+ crate::tray::show(app);
272+ let _ = app.emit_to(EventTarget::webview(crate::frame::SITE), OPEN_EVENT, Opening { section, check });
273+ return;
274+ }
275+ show(app, check);
222276 }
223277
278+/// The runner menu, opened (the tray's runner row) or, from the title
279+/// bar's dot, opened or closed. While the site can't draw it, the Runner
280+/// window.
281+pub fn runner_menu(app: &AppHandle, toggle: bool, at: Option<f64>) {
282+ if in_site(app) {
283+ crate::tray::show(app);
284+ let at = at.filter(|at| at.is_finite()).map(|at| at.clamp(0.0, 1.0));
285+ let menu = RunnerMenu { action: if toggle { "toggle" } else { "open" }, at };
286+ let _ = app.emit_to(EventTarget::webview(crate::frame::SITE), RUNNER_MENU_EVENT, menu);
287+ return;
288+ }
289+ open_runner_window(app);
290+}
291+
224292 /// Opens the Runner window, or brings it forward.
225−pub fn open_runner(app: &AppHandle) {
293+pub fn open_runner_window(app: &AppHandle) {
226294 if !forward(app, RUNNER) {
227295 made_aside(app, Window { label: RUNNER, page: "runner.html", title: "g1t runner", size: (720.0, 760.0) });
228296 }
+4−3
11 //! The tray icon and its menu, and showing and hiding the window. Closing
22 //! the window hides it, unless settings say closing quits; g1t keeps
33 //! running for notifications, and the tray's Open, or the icon, brings it
4−//! back. The runner's state is a row of its own, which opens settings,
5−//! with Start or Stop under it (runner/mod.rs). Quit is here too. The menu's rows are handled with the app
6−//! menu's (menu.rs).
4+//! back. The runner's state is a row of its own, which opens the runner's
5+//! menu under the title bar's dot, with Start or Stop under it
6+//! (runner/mod.rs). Quit is here too. The menu's rows are handled with the
7+//! app menu's (menu.rs).
78
89 use std::sync::Mutex;
910
+2−0
120120 *app.state::<config::State>().update.lock().unwrap() = Some(Ready { version: version.clone(), update, bytes });
121121 tray::update_ready(&version);
122122 let _ = app.emit_to(settings::SETTINGS, settings::READY_EVENT, version.clone());
123+ // The settings the site draws, if they are open, say so too.
124+ let _ = app.emit_to(tauri::EventTarget::webview(crate::frame::SITE), settings::READY_EVENT, version.clone());
123125 if !at_start {
124126 tell(app, "g1t is ready to update", &format!("Version {version} is downloaded. Restart g1t from the tray to use it."));
125127 }
+79−35
8484
8585 ### The title bar
8686
87−g1t draws its own title bar, as Slack and Discord do, so the whole window
88−is g1t and looks the same on Windows, macOS and Linux. It takes the
89−colours of g1t's rail, in light and dark.
87+g1t draws its own title bar, so the whole window is g1t and looks the
88+same on Windows, macOS and Linux. It takes the colours of g1t's rail, in
89+light and dark.
9090
9191 | Part | What it does |
9292 | --- | --- |
9393 | **☰** (Windows and Linux) | Opens the app's [menu](#the-menu). |
9494 | **← →** | Back and forward through the pages you have been on. |
9595 | **Search** | In the middle: searches your workspace, as **Ctrl+K** or **⌘K** does. Before you are signed in, it shows the page's title. |
96−| **The runner** | At the right, beside **Settings**: what [the runner](#the-runner) is doing in a few words, with a dot in its colour (*Runner running*, *Running a job*, *Running 2 jobs*, *Runner starting*, *Runner stopped*, *Runner needs you*), live. It opens [the Runner window](#the-runner-window). In a narrow window only its dot shows. |
97−| **Settings** | Opens [the settings](#settings). |
96+| **The runner's dot** | At the right, right beside **Settings**: a small circle in the colour of what [the runner](#the-runner) is doing, live: green while it runs, blue and breathing while it works (*Running a job*, *Runner starting*, *Runner updating*), amber or red when it needs you, grey when it is stopped or off. Hover it for the words; a wide window shows them beside it. It opens [the runner menu](#the-runner-menu). |
97+| **Settings** | Opens [the settings](#settings) over the page. |
9898 | **Minimize, maximize, close** (Windows and Linux) | At the right. Close keeps g1t in the tray unless [the settings](#settings) say it quits. On macOS the window's own buttons sit at the left of the bar. |
9999
100100 Drag the bar to move the window, and double-click it to maximize or
103103
104104 To have the system's title bar instead, turn on **Use the system's title
105105 bar** in [the settings](#settings). On Windows and Linux the menu bar
106−comes back under it. g1t restarts to change it: the settings window offers
106+comes back under it. g1t restarts to change it: the settings offer
107107 **Restart now**.
108108
109109 ### The menu
124124
125125 ## Settings
126126
127−The app's own settings are in a window of their own: the **Settings**
128−button at the right of the title bar, **Settings** in the File menu (the
129−g1t menu on macOS) or Ctrl+, (⌘, on macOS), **Settings…** in the tray, or
130−**Desktop app settings** in your account menu on g1t. Each change takes effect at
131−once and is kept on this computer. Your account's settings, which things
132−notify you among them, stay on g1t, in [your settings](/guides/notifications/#settings).
127+The app's own settings open over the page you are on, with their
128+sections down the left (**Starting and closing**, **Runner**,
129+**Notifications**, **Window**, **Updates**, **Server**, **About**) and the
130+one you choose on the right. Open them with the **Settings** button at the
131+right of the title bar, **Settings** in the File menu (the g1t menu on
132+macOS) or Ctrl+, (⌘, on macOS), **Settings…** in the tray, or **Desktop
133+app settings** in your account menu on g1t; the window comes forward if it
134+was hidden. Escape or **×** closes them. Each change takes effect at once
135+and is kept on this computer; there is nothing to save. Your account's
136+settings, which things notify you among them, stay on g1t, in
137+[your settings](/guides/notifications/#settings).
133138
139+While the window shows no page of g1t's, such as the page that picks a
140+server, the page g1t shows when it can't reach the server, or signing in
141+with GitHub, the settings open in a window of their own instead, with the
142+same settings.
143+
134144 | Setting | Default | What it does |
135145 | --- | --- | --- |
136146 | **Open g1t when you sign in** | Off | Starts g1t with your computer. The system keeps this: a login item on macOS, a startup entry on Windows, an autostart entry on Linux. |
143153 | **Zoom** | 100% | How large g1t is drawn, from 67% to 200%. The View menu changes it too. |
144154 | **Download updates by themselves** | On | Off, the app never checks on its own; **Check for updates** still does. The runner's updates follow it too: off, only **Check for runner updates** looks for a newer runner. |
145155
146−The settings window also shows the app's version and whether it is up to
147−date, with **Check for updates** (see [Updates](#updates)), and the server it opens, with **Change…** to [use another
148−g1t](#your-own-g1t). The runner is a line there, what it is doing with
149−**Open runner**: its own settings are in [the Runner window](#the-runner-window).
156+**Updates** also shows the app's version and whether it is up to date,
157+with **Check for updates** (see [Updates](#updates)); **Server** shows the
158+server it opens, with **Change…** to [use another g1t](#your-own-g1t); and
159+**Runner** has [the runner's settings](#runner-settings) and a card for
160+each kind of job.
161+
162+A button that asks the app to do something, such as **Check for updates**,
163+**Restart now**, **Retry**, **Start** or **Stop**, shows a spinner and
164+can't be pressed again until what it asked for shows: the runner's state
165+changes, the answer comes, or, after 20 seconds with no change, it is
166+yours to press again. If the app couldn't do it, the settings say why.
150167
151168 ## The runner
152169
157174 opens, and stops when g1t quits. Time on it is free, as on any of your
158175 runners.
159176
160−To stop it, press **Stop running jobs** in the tray; it starts again with
161−**Start running jobs**, or the next time g1t opens. To keep it off, turn
162−off **Run jobs on this computer while g1t is open** in
163−[the Runner window](#the-runner-window).
177+To stop it, press **Stop** in [the runner menu](#the-runner-menu), or
178+**Stop running jobs** in the tray; it starts again with **Start**, or the
179+next time g1t opens. To keep it off, turn off **Run jobs on this computer
180+while g1t is open** in [the runner's settings](#runner-settings).
164181
165−Sandboxed, each kind of job is a card of its own in the Runner window, **Linux jobs** and **Windows jobs**, each with its own **Stop**
182+Sandboxed, each kind of job has its own line in the runner menu and card
183+in the settings, **Linux jobs** and **Windows jobs**, each with its own **Stop**
166184 and **Start**. Stopping Windows jobs leaves Linux jobs running, and the
167185 other way round; a kind you stop stays stopped, across restarts, until you
168186 start it again. Both are on until you stop one.
176194 `-windows`: `build-box-linux` has `linux`, and `build-box-windows` has
177195 `windows`, on a Windows computer with Windows Sandbox.
178196
179−### The Runner window
197+### The runner menu
180198
181−The runner has a window of its own, apart from the settings, as Docker
182−Desktop has for its engine: open it from the chip beside **Settings** in
183−the title bar, from the runner's row in the tray, or with **Open runner**
184−in the settings. It shows what the runner is doing as it happens, and keeps
185−its settings:
199+The runner's dot, right beside **Settings** in the title bar, opens its
200+menu, under the dot. The runner's row in the tray opens it too. It shows,
201+live:
186202
203+- What the runner is doing, how jobs run, the workspace and the runner's
204+ version, with **Stop** or **Start** for the whole runner.
205+- When the runner needs you, why, and the fix with its buttons.
206+- Each kind of job, sandboxed: its state, the job it is running with a link
207+ to its run, a newer runner waiting for it, why it isn't running and the
208+ fix, and its own **Stop** or **Start**.
209+- When the app last looked for a newer runner, with **Check for runner
210+ updates**.
211+- The last lines of the runner's log, with **Open the whole log**.
212+- **Runner settings…**, which opens [the settings](#settings) at Runner.
213+
214+Escape, a click outside it, or the dot again closes it. While the window
215+shows no page of g1t's, the dot and the tray open the Runner window
216+instead: the same, in a window of its own.
217+
218+### Runner settings
219+
220+The settings' **Runner** section has a card for each kind of job, as the
221+menu has them with more detail (its runner's name on g1t, its version and
222+isolation), and these:
223+
187224 | Setting | Default | What it does |
188225 | --- | --- | --- |
189226 | **Run jobs on this computer while g1t is open** | On | Starts the runner a few seconds after the window. Off, nothing runs. |
191228 | **Linux jobs**, **Windows jobs** (sandboxed) | Both on | Each kind of job's card has its own **Stop** and **Start**, which leave the other kind as it is. |
192229 | **Workspace** | The one you were in when it first registered | The workspace whose jobs the runner runs. Lists the workspaces you own. |
193230
231+Choosing **In Docker** or **Directly on this computer** opens the Runner
232+window, a page that comes with the app, and you choose it there. Jobs
233+outside the sandbox can reach your files, so the app takes that choice
234+only from its own pages, never from a page g1t's server sends; going back
235+to **Sandboxed** works from either.
236+
194237 [Seeing it](#seeing-it) says what else it shows.
195238
196239 ### How jobs run
197240
198−**How jobs run** in the Runner window decides what a job on your computer can
241+**How jobs run**, in [the runner's settings](#runner-settings), decides what a job on your computer can
199242 reach. The app never changes it by itself: when the one you chose cannot
200243 start, it says why and runs nothing.
201244
272315 from its registration, signed in or not.
273316
274317 The workspace is the one you were in when it first registered; choose
275−another under **Workspace** in the Runner window. Changing it, or how jobs
318+another under **Workspace** in [the runner's settings](#runner-settings). Changing it, or how jobs
276319 run, waits for a job that is running to end, then removes the old
277320 registration and makes a new one. The sandbox is registered with one
278321 workspace, so moving it to another removes it and makes it again, image
302345 jobs and starts on the new one.
303346
304347 Meanwhile each card says what is waiting: *0.3.2 ready, installs after
305−this job*. **Check for runner updates**, beside your computer's name in
306−the Runner window, looks at once, and installs what it finds the same way. An
348+this job*. **Check for runner updates**, in [the runner menu](#the-runner-menu),
349+looks at once, and installs what it finds the same way. An
307350 update that fails leaves the runner as it was, and the next hour's look
308351 tries again. Turning off **Download updates by themselves** stops the
309352 hourly look; **Check for runner updates** still looks, and installs.
312355
313356 | Where | What it shows |
314357 | --- | --- |
315−| The title bar | Its chip, beside **Settings**: *Runner running*, *Running a job*, with a dot in its colour, which opens the Runner window. |
316−| The tray | A row with what the runner is doing, *Runner: Waiting for jobs*, which opens the Runner window; **Stop running jobs** or **Start running jobs** under it. The icon's hover text says it too. |
317−| [The Runner window](#the-runner-window) | At the top, what the whole runner is doing, as the chip says it, with what it is running, its version and workspace, and its own **Stop** or **Start**. Below, sandboxed, a card for each kind of job this computer can have, side by side: its state (*Running a job*, *Waiting for jobs*, *Starting*, *Downloading image* with how far, *Updating*, *Waiting for image*, *Stopped*, *Not available*, *Problem*), what it is, the job it is running with a link to it, and when it isn't running why and the fix, with the fix's buttons and its own **Stop** or **Start**; below, its runner's name on g1t, version, updates (when the app last looked, *Checked 12 min ago*, and a newer one waiting for it, *0.3.2 ready, installs after this job*) and isolation. Above the cards, only what is about the whole runner: signed out, no workspace, turned off, stopped, the sandbox down. Then its setup: **Run jobs on this computer while g1t is open**, how jobs run, the workspace, and your computer's name, the runner's version and when the app last looked for a newer one, with **Check for runner updates** and **View on g1t**. Last, its log, following along as it is written, with **Open the whole log**. In Docker or directly on this computer, one line says what it is doing, or why it isn't running and the fix. |
358+| The title bar | Its dot, right beside **Settings**, in the colour of what it is doing; hover it for the words (*Runner running*, *Running a job*, *Running 2 jobs*, *Runner starting*, *Runner stopped*, *Runner needs you*), which a wide window shows beside it. It opens the runner menu. |
359+| The tray | A row with what the runner is doing, *Runner: Waiting for jobs*, which opens the runner menu; **Stop running jobs** or **Start running jobs** under it. The icon's hover text says it too. |
360+| [The runner menu](#the-runner-menu) | At the top, what the whole runner is doing, as the dot says it, with how jobs run, its workspace and version, and its own **Stop** or **Start**. When it needs you, only what is about the whole runner: signed out, no workspace, turned off, stopped, the sandbox down, with the fix's buttons. Sandboxed, a line for each kind of job: its state (*Running a job*, *Waiting for jobs*, *Starting*, *Downloading image* with how far, *Updating*, *Waiting for image*, *Stopped*, *Not available*, *Problem*), the job it is running with a link to it, a newer runner waiting for it (*0.3.2 ready, installs after this job*), and when it isn't running why and the fix, with the fix's buttons and its own **Stop** or **Start**. Then when the app last looked for a newer runner, with **Check for runner updates**; the last lines of its log, following along, with **Open the whole log**; and **Runner settings…**. |
361+| [The runner's settings](#runner-settings) | The same, with a card for each kind of job side by side, which adds its runner's name on g1t, version, updates and isolation; then **Run jobs on this computer while g1t is open**, how jobs run, the workspace, and your computer's name, the runner's version and when the app last looked for a newer one, with **View on g1t**. In Docker or directly on this computer, one line says what it is doing, or why it isn't running and the fix. |
318362 | The workspace's [Runners page](/guides/self-hosted-runners/#the-runners-page) | **Desktops**: every computer with the app in the workspace, with what each is doing, or why it isn't running and the fix, in the same words; a sandboxed one's kinds of job as cards of their own, as in the settings, with a link to the run each is running, its version, when the app last looked for updates and a newer one waiting. Inside the app, this computer comes first, live, with its buttons, each kind's **Stop** or **Start** and **Check for runner updates** among them. A computer whose g1t is closed says *g1t is closed on* its name. |
319363
320364 ### When it isn't running
391435
392436 To see where your copy stands, choose **Check for updates…** in the Help
393437 menu or the tray, or **Check for updates** in [the settings](#settings).
394−The menu and the tray open the settings window at its Updates section,
438+The menu and the tray open the settings at their Updates section,
395439 which checks and says the answer there:
396440
397441 - **Up to date. Last checked today at 10:42.** You have the newest version.
+79−0
1+import { useEffect, useState } from "react";
2+
3+import { useDesktopRunner } from "../lib/desktop-runner";
4+import { type SectionId, desktopSettings, sectionOf } from "../lib/desktop-settings";
5+import { DesktopRunnerMenu } from "./desktop-runner-menu";
6+import { DesktopSettingsDialog } from "./desktop-settings";
7+
8+/** Where the dot sits when the app doesn't say: near the right of the title bar, before the window's buttons. */
9+const DOT_AT = 0.86;
10+
11+/**
12+ * Inside the desktop app, on every page: the app's settings dialog and the
13+ * runner's menu, which this page draws over itself since it fills the
14+ * window under the title bar (lib/desktop-settings.ts). It tells the app it
15+ * can as soon as it mounts, and no longer when it goes, so the app opens
16+ * them here rather than in windows of their own. It draws nothing until
17+ * the app asks. Outside the app, and in an app too old to ask, it is
18+ * nothing at all.
19+ */
20+export function DesktopApp() {
21+ // Found once hydrated: the server never has it, and the first render matches the server's.
22+ const [bridge, setBridge] = useState<ReturnType<typeof desktopSettings>>(null);
23+ useEffect(() => setBridge(desktopSettings()), []);
24+ const runner = useDesktopRunner();
25+ const [settings, setSettings] = useState<{ open: boolean; section: SectionId; check: number }>({ open: false, section: "general", check: 0 });
26+ const [menu, setMenu] = useState<{ open: boolean; at: number }>({ open: false, at: DOT_AT });
27+
28+ useEffect(() => {
29+ if (!bridge) return;
30+ const stopOpen = bridge.onOpen((opening) => {
31+ setMenu((now) => ({ ...now, open: false }));
32+ setSettings((now) => ({
33+ open: true,
34+ // Opened again with no section named, it opens where it was.
35+ section: opening?.section ? sectionOf(opening.section) : now.open ? now.section : "general",
36+ check: opening?.check ? now.check + 1 : now.check,
37+ }));
38+ });
39+ const stopMenu = bridge.onRunnerMenu((asked) => {
40+ const at = typeof asked?.at === "number" && Number.isFinite(asked.at) ? asked.at : null;
41+ setMenu((now) => ({ open: asked?.action === "toggle" ? !now.open : true, at: at ?? now.at }));
42+ });
43+ void bridge.ready(true).catch(() => {});
44+ return () => {
45+ stopOpen();
46+ stopMenu();
47+ void bridge.ready(false).catch(() => {});
48+ };
49+ }, [bridge]);
50+
51+ // The title bar's dot shows whether its menu is open.
52+ useEffect(() => {
53+ bridge?.setRunnerMenuOpen(menu.open);
54+ }, [bridge, menu.open]);
55+
56+ if (!bridge) return null;
57+ return (
58+ <>
59+ <DesktopRunnerMenu
60+ open={menu.open}
61+ at={menu.at}
62+ runner={runner}
63+ onOpenChange={(open) => setMenu((now) => ({ ...now, open }))}
64+ onSettings={() => {
65+ setMenu((now) => ({ ...now, open: false }));
66+ setSettings((now) => ({ ...now, open: true, section: "runner" }));
67+ }}
68+ />
69+ <DesktopSettingsDialog
70+ open={settings.open}
71+ section={settings.section}
72+ check={settings.check}
73+ runner={runner}
74+ onOpenChange={(open) => setSettings((now) => ({ ...now, open }))}
75+ onSection={(section) => setSettings((now) => ({ ...now, section }))}
76+ />
77+ </>
78+ );
79+}
+247−0
1+import { Settings2 } from "lucide-react";
2+import { useCallback, useLayoutEffect, useRef, useState } from "react";
3+import { Link } from "react-router";
4+
5+import { useBusy } from "../lib/busy";
6+import { BACKEND_LABEL, type DesktopRunnerCapability, type DesktopRunnerView, desktopRunner, jobHref, kindStatusKey, runnerStatusKey } from "../lib/desktop-runner";
7+import { cn } from "../lib/cn";
8+import { ActButton, Actions, BADGE, DOT, ICON, Toggle } from "./desktop-runners";
9+import { TimeAgo } from "./ui";
10+import { Badge } from "./ui/badge";
11+import { Button } from "./ui/button";
12+import { Popover, PopoverAnchor, PopoverContent } from "./ui/popover";
13+import { Progress } from "./ui/progress";
14+import { Separator } from "./ui/separator";
15+
16+/** How many of the log's last lines the menu shows. */
17+const LOG_LINES = 6;
18+
19+/**
20+ * The runner's menu, hanging under its dot in the desktop app's title bar,
21+ * beside the settings button: what the runner is doing, the whole
22+ * runner's line when it needs the person, each kind of job with the job it
23+ * runs and its own Start or Stop, Start or Stop for the whole runner, its
24+ * updates, the last of its log, and the way to its settings. The title bar
25+ * is a page of its own above this one, so the menu is drawn here, anchored
26+ * at `at` (where the dot is, as a share of the window's width) along the
27+ * top edge. Escape, a click outside, or the dot again closes it.
28+ */
29+export function DesktopRunnerMenu({
30+ open,
31+ at,
32+ runner,
33+ onOpenChange,
34+ onSettings,
35+}: {
36+ open: boolean;
37+ /** Where the dot's right edge is across the window, 0 to 1. */
38+ at: number;
39+ runner: DesktopRunnerView | null;
40+ onOpenChange: (open: boolean) => void;
41+ /** "Runner settings…": the settings dialog at its Runner section. */
42+ onSettings: () => void;
43+}) {
44+ const [error, setError] = useState<string | null>(null);
45+ const fail = useCallback((reason: unknown) => setError(reason instanceof Error ? reason.message : typeof reason === "string" ? reason : "That didn't work."), []);
46+ return (
47+ <Popover
48+ open={open && runner !== null}
49+ onOpenChange={(next) => {
50+ if (!next) setError(null);
51+ onOpenChange(next);
52+ }}
53+ >
54+ {/* The dot is in the title bar, above this page: the menu hangs from the top edge under it. */}
55+ <PopoverAnchor asChild>
56+ <span aria-hidden className="pointer-events-none fixed top-0 size-0" style={{ left: `${at * 100}%` }} />
57+ </PopoverAnchor>
58+ <PopoverContent
59+ align="end"
60+ side="bottom"
61+ sideOffset={4}
62+ aria-label="Runner"
63+ // Focus goes to the menu itself, so Escape and Tab work, without a button looking chosen.
64+ onOpenAutoFocus={(event) => {
65+ event.preventDefault();
66+ (event.currentTarget as HTMLElement | null)?.focus();
67+ }}
68+ className="w-92 max-h-[calc(var(--radix-popover-content-available-height)-0.5rem)] overflow-y-auto p-0"
69+ >
70+ {runner && <MenuBody runner={runner} fail={fail} onSettings={onSettings} />}
71+ {error && (
72+ <p role="alert" className="border-t border-line px-4 py-2.5 text-xs text-danger">
73+ {error}
74+ </p>
75+ )}
76+ </PopoverContent>
77+ </Popover>
78+ );
79+}
80+
81+function MenuBody({ runner, fail, onSettings }: { runner: DesktopRunnerView; fail: (reason: unknown) => void; onSettings: () => void }) {
82+ const status = runnerStatusKey(runner);
83+ const diagnosis = runner.diagnosis;
84+ const kinds = diagnosis.capabilities ?? [];
85+ const chip = runner.chip ?? { tone: diagnosis.tone, label: diagnosis.title };
86+ // The whole runner's line, when the kinds of job don't already say it all.
87+ const line = !(diagnosis.in_capabilities && kinds.length > 0);
88+ const on = runner.enabled && !runner.paused;
89+ const engine = useBusy(status, fail);
90+ const update = diagnosis.update ?? null;
91+ const log = runner.log.slice(-LOG_LINES);
92+ const working = chip.tone === "working";
93+ return (
94+ <>
95+ <div className="flex items-center gap-3 px-4 pt-3.5 pb-3">
96+ <span className={cn("size-2.5 shrink-0 rounded-full", DOT[chip.tone], working && "motion-safe:animate-pulse")} aria-hidden />
97+ <div className="min-w-0 grow">
98+ <p className="truncate text-sm font-medium text-fg" role="status">
99+ {chip.label}
100+ </p>
101+ <p className="truncate text-xs text-muted">
102+ {[BACKEND_LABEL[runner.backend], runner.workspace ? `for ${runner.workspace}` : null, runner.version ? `g1t-runner ${runner.version}` : null].filter(Boolean).join(" · ")}
103+ </p>
104+ </div>
105+ <Button
106+ size="xs"
107+ variant={on ? "outline" : "soft"}
108+ busy={engine.busy}
109+ aria-label={on ? "Stop the runner" : "Start the runner"}
110+ onClick={() => engine.run(() => desktopRunner()?.act(on ? "stop" : "start"))}
111+ >
112+ {on ? "Stop" : "Start"}
113+ </Button>
114+ </div>
115+
116+ {line && (
117+ <div className="space-y-1 border-t border-line px-4 py-3 text-sm">
118+ <p className="font-medium text-fg">{diagnosis.title}</p>
119+ {diagnosis.cause && <p className="text-muted">{diagnosis.cause}</p>}
120+ {diagnosis.fix && <p className="text-fg-soft">{diagnosis.fix}</p>}
121+ {diagnosis.actions.length > 0 && (
122+ <div className="flex flex-wrap gap-2 pt-1.5">
123+ <Actions actions={diagnosis.actions} status={status} onError={fail} />
124+ </div>
125+ )}
126+ </div>
127+ )}
128+
129+ {kinds.length > 0 && (
130+ <ul className="divide-y divide-line border-t border-line" aria-label="Kinds of job this computer runs">
131+ {kinds.map((kind) => (
132+ <KindRow key={kind.id} kind={kind} runner={runner} fail={fail} />
133+ ))}
134+ </ul>
135+ )}
136+
137+ {update && (
138+ <div className="flex items-center gap-3 border-t border-line px-4 py-2.5">
139+ <p className="min-w-0 grow text-xs text-muted">
140+ {update.note ? <span className="block text-accent">{update.note}</span> : null}
141+ {update.checked_at ? (
142+ <>
143+ Checked <TimeAgo at={update.checked_at} />
144+ </>
145+ ) : (
146+ update.checked
147+ )}
148+ </p>
149+ <Actions actions={[update.check]} primary={false} status={status} working={update.checking} onError={fail} />
150+ </div>
151+ )}
152+
153+ <div className="border-t border-line px-4 py-3">
154+ <LogTail lines={log} />
155+ <div className="mt-2 flex justify-end">
156+ <ActButton action="view_log" label="Open the whole log" status={status} fail={fail} />
157+ </div>
158+ </div>
159+
160+ <Separator />
161+ <div className="p-1.5">
162+ <Button variant="ghost" size="sm" className="w-full justify-start" onClick={onSettings}>
163+ <Settings2 size={15} aria-hidden />
164+ Runner settings…
165+ </Button>
166+ </div>
167+ </>
168+ );
169+}
170+
171+/** The log's last lines, kept scrolled to the newest unless scrolled back to read. */
172+function LogTail({ lines }: { lines: string[] }) {
173+ const box = useRef<HTMLPreElement>(null);
174+ const following = useRef(true);
175+ useLayoutEffect(() => {
176+ const pre = box.current;
177+ if (pre && following.current) pre.scrollTop = pre.scrollHeight;
178+ }, [lines]);
179+ return (
180+ <pre
181+ ref={box}
182+ tabIndex={0}
183+ aria-label="The runner's recent log"
184+ onScroll={(event) => {
185+ const pre = event.currentTarget;
186+ following.current = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 8;
187+ }}
188+ className="max-h-32 overflow-auto rounded-md bg-bg px-2.5 py-2 font-mono text-[0.6875rem] leading-relaxed whitespace-pre-wrap wrap-break-word text-muted"
189+ >
190+ {lines.length ? lines.join("\n") : "Nothing yet."}
191+ </pre>
192+ );
193+}
194+
195+/** One kind of job, in a line: its state, the job it runs, a newer runner waiting, its fixes, and its own Start or Stop. */
196+function KindRow({ kind, runner, fail }: { kind: DesktopRunnerCapability; runner: DesktopRunnerView; fail: (reason: unknown) => void }) {
197+ const status = kindStatusKey(kind, runner);
198+ const href = jobHref(kind, []);
199+ const live = kind.state === "busy" || kind.state === "starting" || kind.state === "downloading" || kind.state === "updating";
200+ const urgent = kind.tone === "problem" || (kind.tone === "warning" && kind.state !== "waiting");
201+ return (
202+ <li className="space-y-2 px-4 py-3">
203+ <div className="flex items-center gap-2.5">
204+ <span className={cn("grid size-7 shrink-0 place-items-center rounded-md bg-raised", kind.enabled === false ? "text-faint" : "text-fg-soft")}>{ICON[kind.id] ?? ICON.linux}</span>
205+ <div className="min-w-0 grow">
206+ <p className="truncate text-sm font-medium text-fg">{kind.label}</p>
207+ <Badge tone={BADGE[kind.tone]} role="status">
208+ <span className={cn("size-1.5 rounded-full bg-current", live && "motion-safe:animate-pulse")} aria-hidden />
209+ {kind.title}
210+ </Badge>
211+ </div>
212+ {kind.can_toggle && <Toggle capability={kind} status={status} onError={fail} />}
213+ </div>
214+ {kind.job && (
215+ <p className="truncate pl-9.5 text-xs text-muted">
216+ {kind.job.kind === "agent" ? "Agent work: " : "Running "}
217+ {href ? (
218+ <Link to={href} className="font-medium text-fg underline-offset-4 hover:underline">
219+ {kind.job.name}
220+ </Link>
221+ ) : (
222+ <span className="font-medium text-fg">{kind.job.name}</span>
223+ )}{" "}
224+ in {kind.job.repo}
225+ </p>
226+ )}
227+ {kind.state === "downloading" && typeof kind.progress === "number" && (
228+ <div className="flex items-center gap-2 pl-9.5">
229+ <Progress value={kind.progress} aria-label={`${kind.label}: the sandbox's image`} className="h-1" />
230+ <span className="w-8 shrink-0 text-right text-[0.6875rem] text-muted tabular-nums">{kind.progress}%</span>
231+ </div>
232+ )}
233+ {(kind.cause || kind.fix) && kind.tone !== "ok" && (
234+ <div className="space-y-0.5 pl-9.5 text-xs">
235+ {kind.cause && <p className="text-muted">{kind.cause}</p>}
236+ {kind.fix && <p className="text-fg-soft">{kind.fix}</p>}
237+ </div>
238+ )}
239+ {kind.update && <p className="pl-9.5 text-xs text-accent">{kind.update}</p>}
240+ {kind.actions.length > 0 && (
241+ <div className="flex flex-wrap gap-2 pl-9.5">
242+ <Actions actions={kind.actions} primary={urgent} status={status} onError={fail} />
243+ </div>
244+ )}
245+ </li>
246+ );
247+}
+135−28
44
55 import type { DesktopBackend, DesktopDiagnosis, DesktopRunner, DesktopUpdate, Runner } from "@g1t/contracts";
66
7−import { BACKEND_LABEL, type DesktopRunnerAction, type DesktopRunnerCapability, desktopRunner, jobHref, useDesktopRunner } from "../lib/desktop-runner";
7+import { useBusy } from "../lib/busy";
8+import { BACKEND_LABEL, type DesktopRunnerAction, type DesktopRunnerCapability, type DesktopRunnerView, desktopRunner, jobHref, kindStatusKey, runnerStatusKey, useDesktopRunner } from "../lib/desktop-runner";
89 import { EmptyState, TimeAgo } from "./ui";
10+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "./ui/alert-dialog";
911 import { Badge, type BadgeTone } from "./ui/badge";
1012 import { Button } from "./ui/button";
1113 import { Card } from "./ui/card";
1315 import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
1416
1517 /** A diagnosis's tone, as a dot. */
16−const DOT: Record<DesktopDiagnosis["tone"], string> = {
18+export const DOT: Record<DesktopDiagnosis["tone"], string> = {
1719 ok: "bg-success",
1820 working: "bg-info",
1921 off: "bg-faint",
2224 };
2325
2426 /** A kind of job's tone, as its label's. */
25−const BADGE: Record<DesktopDiagnosis["tone"], BadgeTone> = {
27+export const BADGE: Record<DesktopDiagnosis["tone"], BadgeTone> = {
2628 ok: "success",
2729 working: "info",
2830 off: "neutral",
3133 };
3234
3335 /** Each kind of job's icon: a terminal for Linux jobs, a window for Windows jobs, a laptop for macOS jobs. */
34−const ICON: Record<string, ReactNode> = {
36+export const ICON: Record<string, ReactNode> = {
3537 linux: <SquareTerminal size={15} aria-hidden />,
3638 windows: <AppWindow size={15} aria-hidden />,
3739 macos: <Laptop size={15} aria-hidden />,
6163 update: DesktopUpdate | null;
6264 /** Here only: the app's button that looks for a newer runner now. */
6365 check: DesktopRunnerAction | null;
66+ /** Here only: the app is looking for a newer runner now. */
67+ checking: boolean;
6468 };
6569
6670 /** The desktops to list: this computer, live, first; then the others, as their apps last said, or closed. */
8084 here: true,
8185 reportedAt: null,
8286 update: here.diagnosis.update ?? null,
83− check: here.diagnosis.update?.checking ? null : (here.diagnosis.update?.check ?? null),
87+ check: here.diagnosis.update?.check ?? null,
88+ checking: !!here.diagnosis.update?.checking,
8489 });
8590 }
8691 for (const desktop of desktops) {
99104 reportedAt: desktop.reportedAt,
100105 update: desktop.open ? (desktop.diagnosis.update ?? null) : null,
101106 check: null,
107+ checking: false,
102108 });
103109 }
104110 return rows;
105111 }
106112
107−/** A diagnosis's fixes, as buttons: they work only on this computer, in the app. */
108−function Actions({ actions, primary = true }: { actions: DesktopRunnerAction[]; primary?: boolean }) {
113+/**
114+ * A diagnosis's fixes, as buttons: they work only on this computer, in the
115+ * app. Each is busy from its click until the status it waits on changes
116+ * (`status`, lib/busy.ts); one the app asks about first asks.
117+ */
118+export function Actions({
119+ actions,
120+ primary = true,
121+ status,
122+ working = false,
123+ onError,
124+}: {
125+ actions: DesktopRunnerAction[];
126+ primary?: boolean;
127+ /** What a press waits on: the runner's status (`runnerStatusKey`, `kindStatusKey`). */
128+ status: string;
129+ /** Busy for a reason of the app's own, such as a check already under way. */
130+ working?: boolean;
131+ onError?: (error: unknown) => void;
132+}) {
109133 if (actions.length === 0) return null;
110134 return (
111135 <>
112136 {actions.map((action, at) => (
113− <Button key={action.id} size="xs" variant={primary && at === 0 ? "secondary" : "outline"} onClick={() => void desktopRunner()?.act(action.id).catch(() => {})}>
114− {action.label}
115− </Button>
137+ <ActionButton key={action.id} action={action} variant={primary && at === 0 ? "secondary" : "outline"} status={status} working={working} onError={onError} />
116138 ))}
117139 </>
118140 );
119141 }
120142
121−/** Start or Stop for one kind of job on this computer, which leaves the others as they are. */
122−function Toggle({ capability }: { capability: DesktopRunnerCapability }) {
123− const [asked, setAsked] = useState<boolean | null>(null);
143+function ActionButton({
144+ action,
145+ variant,
146+ status,
147+ working,
148+ onError,
149+}: {
150+ action: DesktopRunnerAction;
151+ variant: "secondary" | "outline";
152+ status: string;
153+ working: boolean;
154+ onError?: (error: unknown) => void;
155+}) {
156+ const { busy, run } = useBusy(status, onError);
157+ const [asking, setAsking] = useState(false);
158+ const go = () => run(() => desktopRunner()?.act(action.id), !!action.waits);
159+ // The app's question, as a title and what it means: "Remove this computer's sandbox?" and the rest.
160+ const [title, ...rest] = (action.confirm ?? "").split(/(?<=\?)\s+/);
161+ return (
162+ <>
163+ <Button size="xs" variant={variant} busy={busy || working} onClick={() => (action.confirm ? setAsking(true) : go())}>
164+ {action.label}
165+ </Button>
166+ {action.confirm && (
167+ <AlertDialog open={asking} onOpenChange={setAsking}>
168+ <AlertDialogContent>
169+ <AlertDialogHeader>
170+ <AlertDialogTitle>{title}</AlertDialogTitle>
171+ {rest.length > 0 && <AlertDialogDescription>{rest.join(" ")}</AlertDialogDescription>}
172+ </AlertDialogHeader>
173+ <AlertDialogFooter>
174+ <AlertDialogCancel>Cancel</AlertDialogCancel>
175+ <AlertDialogAction onClick={go}>{action.label}</AlertDialogAction>
176+ </AlertDialogFooter>
177+ </AlertDialogContent>
178+ </AlertDialog>
179+ )}
180+ </>
181+ );
182+}
183+
184+/**
185+ * Start or Stop for one kind of job on this computer, which leaves the
186+ * others as they are: busy from the click until its status says it
187+ * started or stopped.
188+ */
189+export function Toggle({ capability, status, onError }: { capability: DesktopRunnerCapability; status: string; onError?: (error: unknown) => void }) {
190+ const { busy, run } = useBusy(status, onError);
124191 const on = capability.enabled !== false;
125− const waiting = asked !== null && asked !== on;
126192 return (
127193 <Button
128194 size="xs"
129195 variant={on ? "outline" : "soft"}
130196 className="ml-auto"
131− disabled={waiting}
197+ busy={busy}
132198 aria-label={`${on ? "Stop" : "Start"} ${capability.label}`}
133− onClick={() => {
134− setAsked(!on);
135− void desktopRunner()
136− ?.act(on ? "stop" : "start", capability.id)
137− .catch(() => setAsked(null));
138− }}
199+ onClick={() => run(() => desktopRunner()?.act(on ? "stop" : "start", capability.id))}
139200 >
140− {waiting ? (on ? "Stopping…" : "Starting…") : on ? "Stop" : "Start"}
201+ {on ? "Stop" : "Start"}
141202 </Button>
142203 );
143204 }
144205
206+/**
207+ * A button that asks the runner for something by its id: busy until the
208+ * app has it (a page, a window, the log), or with `waits` until the
209+ * status that follows changes.
210+ */
211+export function ActButton({
212+ action,
213+ label,
214+ status,
215+ fail,
216+ waits = false,
217+}: {
218+ action: string;
219+ label: string;
220+ status: string;
221+ fail?: (error: unknown) => void;
222+ waits?: boolean;
223+}) {
224+ const { busy, run } = useBusy(status, fail);
225+ return (
226+ <Button size="xs" variant="outline" busy={busy} onClick={() => run(() => desktopRunner()?.act(action), waits)}>
227+ {label}
228+ </Button>
229+ );
230+}
231+
145232 function Detail({ term, children }: { term: ReactNode; children: ReactNode }) {
146233 return (
147234 <div className="flex min-w-0 items-baseline gap-3">
162249 }
163250
164251 /** One kind of job, as a card of its own: its state, what it runs, why it doesn't and the fix, and here its own Start or Stop. */
165−function KindCard({ capability, here, runners, update }: { capability: DesktopRunnerCapability; here: boolean; runners: Runner[]; update: DesktopUpdate | null }) {
252+export function KindCard({
253+ capability,
254+ here,
255+ runners,
256+ update,
257+ view = null,
258+ onError,
259+}: {
260+ capability: DesktopRunnerCapability;
261+ here: boolean;
262+ runners: Runner[];
263+ update: DesktopUpdate | null;
264+ /** Here: the runner as the app says it now, which its buttons wait on. */
265+ view?: DesktopRunnerView | null;
266+ onError?: (error: unknown) => void;
267+}) {
166268 const href = jobHref(capability, runners);
167269 const isolation = capability.isolation ? (ISOLATION[capability.isolation] ?? { label: capability.isolation, hint: null }) : null;
168270 const edge = capability.tone === "problem" ? "border-danger/40" : capability.tone === "warning" ? "border-warn/35" : "";
240342 )}
241343 {(fixes.length > 0 || toggle) && (
242344 <div className="mt-auto flex flex-wrap items-center gap-2">
243− <Actions actions={fixes} primary={capability.tone === "problem" || (capability.tone === "warning" && capability.state !== "waiting")} />
244− {toggle && <Toggle key={String(capability.enabled)} capability={capability} />}
345+ <Actions
346+ actions={fixes}
347+ primary={capability.tone === "problem" || (capability.tone === "warning" && capability.state !== "waiting")}
348+ status={kindStatusKey(capability, view)}
349+ onError={onError}
350+ />
351+ {toggle && <Toggle capability={capability} status={kindStatusKey(capability, view)} onError={onError} />}
245352 </div>
246353 )}
247354 </Card>
299406 )}
300407 {row.check && (
301408 <div className="mt-2">
302− <Actions actions={[row.check]} primary={false} />
409+ <Actions actions={[row.check]} primary={false} status={here ? runnerStatusKey(here) : ""} working={row.checking} />
303410 </div>
304411 )}
305412 </div>
313420 {row.diagnosis.fix && <p className="mt-0.5 text-sm text-fg-soft">{row.diagnosis.fix}</p>}
314421 {row.actions.length > 0 && (
315422 <div className="mt-2 flex flex-wrap gap-2">
316− <Actions actions={row.actions} />
423+ <Actions actions={row.actions} status={here ? runnerStatusKey(here) : ""} />
317424 </div>
318425 )}
319426 </div>
322429 {row.capabilities.length > 0 && (
323430 <div className={`grid gap-3 ${row.capabilities.length > 1 ? "sm:grid-cols-2" : ""}`}>
324431 {row.capabilities.map((capability) => (
325− <KindCard key={capability.id} capability={capability} here={row.here} runners={runners} update={row.update} />
432+ <KindCard key={capability.id} capability={capability} here={row.here} runners={runners} update={row.update} view={row.here ? here : null} />
326433 ))}
327434 </div>
328435 )}
+450−0
Loading the diff…
+31−4
Loading the diff…
+21−0
Loading the diff…
+59−0
Loading the diff…
+28−2
Loading the diff…
+62−0
Loading the diff…
+186−0
Loading the diff…
+10−1
Loading the diff…
+3−0
Loading the diff…

Comments

Sign in to comment.