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.

577 linesCodeBlameRaw
1// The Runner window's work: the runner on its own, live. What it is
2// doing, each kind of job and the job each is running, how jobs run and for
3// which workspace, its updates and its log, from the app's status
4// (src/runner/mod.rs View, in diagnosis.rs's words), and each change kept
5// the moment it is made. Everything it asks is a command in src/bridge.rs.
6(function () {
7 function byId(id) {
8 return document.getElementById(id);
9 }
10
11 function invoke(command, args) {
12 var core = window.__TAURI__ && window.__TAURI__.core;
13 if (core && typeof core.invoke === "function") return core.invoke(command, args || {});
14 return Promise.reject(new Error("This page belongs inside the g1t desktop app."));
15 }
16
17 function fail(error) {
18 var box = byId("error");
19 box.textContent = typeof error === "string" ? error : (error && error.message) || "That could not be changed.";
20 box.hidden = false;
21 }
22
23 // What each way of running jobs can reach, said plainly (src/runner/backend.rs).
24 var BACKENDS = {
25 sandboxed: { text: "The runner and every job run in a sandbox of their own. Nothing a job does reaches this computer.", warn: false },
26 docker: {
27 text: "Each job runs in a container of its own, but the runner controls this computer's Docker, which can reach your files. Not a sandbox.",
28 warn: true,
29 },
30 host: {
31 text: "Jobs run directly on this computer, as you: they can read, change and delete anything your account can. For jobs that need this system itself, such as building a Windows installer.",
32 warn: true,
33 },
34 };
35 var current = null;
36
37 // ── The runner ──────────────────────────────────────────────────────
38 // Its diagnosis, from src/runner/diagnosis.rs, as the tray and the
39 // site's Runners page show it; its buttons are the fixes.
40 function option(value, text) {
41 var item = document.createElement("option");
42 item.value = value;
43 item.textContent = text;
44 return item;
45 }
46
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([
55 d.code,
56 d.tone,
57 d.title,
58 d.cause,
59 runner.chip.label,
60 runner.enabled,
61 runner.paused,
62 update.checking,
63 update.checked_at,
64 update.ready,
65 ]);
66 }
67
68 // One kind of job's, with the whole runner's.
69 function kindStatus(kind, runner) {
70 return JSON.stringify([runnerStatus(runner), kind.state, kind.enabled, kind.tone, kind.title, kind.cause]);
71 }
72
73 // A fix's button. Choosing is done here, in this window; a copy says it
74 // was copied. One that changes the runner (the catalogue says which:
75 // `waits`) stays busy until the status that follows; one the catalogue
76 // asks about first (`confirm`) asks.
77 function actionButton(action, primary, key, status) {
78 var button = document.createElement("button");
79 button.type = "button";
80 button.textContent = action.label;
81 if (primary) button.className = "primary";
82 key = key + ":" + action.id;
83 if (busy.pressed(key, status)) busy.start(button);
84 button.addEventListener("click", function () {
85 if (action.id === "choose_workspace") return byId("runnerWorkspace").focus();
86 if (action.id === "choose_backend") return byId("runnerBackend").focus();
87 if (action.confirm && !window.confirm(action.confirm)) return;
88 busy
89 .run(
90 button,
91 key,
92 status,
93 !!action.waits,
94 function () {
95 return invoke("runner_control", { action: action.id });
96 },
97 fail,
98 )
99 .then(function (done) {
100 if (!done || action.id.indexOf("copy_") !== 0) return;
101 button.textContent = "Copied";
102 setTimeout(function () {
103 button.textContent = action.label;
104 }, 2000);
105 });
106 });
107 return button;
108 }
109
110 function element(tag, className, text) {
111 var item = document.createElement(tag);
112 if (className) item.className = className;
113 if (text != null) item.textContent = text;
114 return item;
115 }
116
117 // The catalogue's sentences name commands in backticks: `wsl --install`.
118 // Said here as code, which can be selected and copied.
119 function prose(into, text) {
120 into.textContent = "";
121 String(text || "")
122 .split("`")
123 .forEach(function (part, at) {
124 if (!part) return;
125 into.appendChild(at % 2 ? element("code", null, part) : document.createTextNode(part));
126 });
127 return into;
128 }
129
130 // ── Each kind of job, a card of its own ───────────────────────────────
131 // Linux jobs and Windows jobs (macOS jobs once they are released): what
132 // each is doing, why one isn't running and its fix, and its own Start or
133 // Stop, which leaves the other as it is. The words are the app's
134 // (CapabilityLine in src/runner/diagnosis.rs); the card only lays them out.
135 var SVG = "http://www.w3.org/2000/svg";
136 // Plain line icons, 16px on a 24px grid: a terminal for Linux jobs, a
137 // window for Windows jobs, a laptop for macOS jobs.
138 var ICONS = {
139 linux: ["M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z", "M7 10l3 2.5L7 15", "M12.5 15h4"],
140 windows: ["M4 4h16a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1z", "M3 9h18", "M6.5 6.5h.01", "M9 6.5h.01"],
141 macos: ["M5 6a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v9H5z", "M2.5 18.5h19", "M3.5 18.5L5 15h14l1.5 3.5"],
142 };
143 // How each runner keeps a job from this computer, said plainly.
144 var ISOLATION = { "nested-container": "A fresh container in the VM", vm: "A VM of its own", container: "A container of its own", host: "None" };
145 var BUSY = { busy: true, starting: true, downloading: true, updating: true };
146 var cards = {};
147
148 // A small arrow: the link goes out to g1t.
149 function outward() {
150 var svg = document.createElementNS(SVG, "svg");
151 svg.setAttribute("viewBox", "0 0 24 24");
152 svg.setAttribute("width", "11");
153 svg.setAttribute("height", "11");
154 svg.setAttribute("fill", "none");
155 svg.setAttribute("stroke", "currentColor");
156 svg.setAttribute("stroke-width", "2");
157 svg.setAttribute("stroke-linecap", "round");
158 svg.setAttribute("aria-hidden", "true");
159 var path = document.createElementNS(SVG, "path");
160 path.setAttribute("d", "M7 17L17 7M9 7h8v8");
161 svg.appendChild(path);
162 return svg;
163 }
164
165 function icon(id) {
166 var svg = document.createElementNS(SVG, "svg");
167 svg.setAttribute("viewBox", "0 0 24 24");
168 svg.setAttribute("width", "16");
169 svg.setAttribute("height", "16");
170 svg.setAttribute("fill", "none");
171 svg.setAttribute("stroke", "currentColor");
172 svg.setAttribute("stroke-width", "1.75");
173 svg.setAttribute("stroke-linecap", "round");
174 svg.setAttribute("stroke-linejoin", "round");
175 svg.setAttribute("aria-hidden", "true");
176 (ICONS[id] || ICONS.linux).forEach(function (d) {
177 var path = document.createElementNS(SVG, "path");
178 path.setAttribute("d", d);
179 svg.appendChild(path);
180 });
181 return svg;
182 }
183
184 // A card's parts, made once and filled in on every status.
185 function makeCard(kind) {
186 var card = element("article", "kind");
187 card.setAttribute("role", "listitem");
188 card.setAttribute("aria-labelledby", "kind-" + kind.id + "-title");
189 var head = element("header", "kind-head");
190 var badge = element("span", "kind-icon");
191 badge.appendChild(icon(kind.id));
192 var name = element("span", "kind-name");
193 var title = element("h3", null, kind.label);
194 title.id = "kind-" + kind.id + "-title";
195 name.appendChild(title);
196 var pill = element("span", "pill");
197 // Its state is said when it changes, as "Linux jobs: Waiting for jobs".
198 pill.setAttribute("role", "status");
199 pill.setAttribute("aria-live", "polite");
200 pill.setAttribute("aria-atomic", "true");
201 var said = element("span", "visually-hidden", kind.label + ": ");
202 pill.appendChild(said);
203 pill.appendChild(element("span", "pill-dot"));
204 pill.appendChild(element("span", "pill-text"));
205 name.appendChild(pill);
206 head.appendChild(badge);
207 head.appendChild(name);
208 card.appendChild(head);
209 card.appendChild(element("p", "kind-about"));
210 var body = element("div", "kind-body");
211 card.appendChild(body);
212 var details = element("dl", "kind-details");
213 card.appendChild(details);
214 var actions = element("div", "kind-actions");
215 card.appendChild(actions);
216 return { root: card, pill: pill, about: card.querySelector(".kind-about"), body: body, details: details, actions: actions, signature: "" };
217 }
218
219 // What it is running, with where to see it on g1t.
220 function jobRow(job) {
221 var row = element("div", "kind-job");
222 row.appendChild(element("span", "kind-job-label", "Running"));
223 var what = element("span", "kind-job-what");
224 if (job.link) {
225 var link = element("a", null, job.name);
226 link.href = job.link;
227 what.appendChild(link);
228 } else {
229 what.appendChild(element("strong", null, job.name));
230 }
231 if (job.repo) what.appendChild(element("span", "muted", " in " + job.repo));
232 row.appendChild(what);
233 return row;
234 }
235
236 function progressRow(kind) {
237 var row = element("div", "kind-progress");
238 var bar = element("div", "bar");
239 bar.setAttribute("role", "progressbar");
240 bar.setAttribute("aria-valuemin", "0");
241 bar.setAttribute("aria-valuemax", "100");
242 bar.setAttribute("aria-valuenow", String(kind.progress));
243 bar.setAttribute("aria-label", kind.label + ": the sandbox's image");
244 var fill = element("span");
245 fill.style.width = Math.max(2, Math.min(100, kind.progress)) + "%";
246 bar.appendChild(fill);
247 row.appendChild(bar);
248 row.appendChild(element("span", "muted small", kind.progress + "%"));
249 return row;
250 }
251
252 function detail(list, term, value, given) {
253 if (!value) return;
254 var row = element("div");
255 row.appendChild(element("dt", null, term));
256 var dd = element("dd", null, given ? null : value);
257 if (given) dd.appendChild(given);
258 row.appendChild(dd);
259 list.appendChild(row);
260 }
261
262 // Start or Stop for one kind of job, said for which: busy from the click
263 // until the status that follows says it started or stopped.
264 function toggleButton(kind, onFirst, runner) {
265 var on = kind.enabled;
266 var button = element("button", on ? "quiet" : onFirst ? "primary" : null);
267 button.type = "button";
268 button.textContent = on ? "Stop" : "Start";
269 button.setAttribute("aria-label", (on ? "Stop " : "Start ") + kind.label);
270 var key = "toggle:" + kind.id;
271 var status = kindStatus(kind, runner);
272 if (busy.pressed(key, status)) busy.start(button);
273 button.addEventListener("click", function () {
274 busy.run(
275 button,
276 key,
277 status,
278 true,
279 function () {
280 return invoke("runner_control", { action: on ? "stop" : "start", capability: kind.id });
281 },
282 fail,
283 );
284 });
285 return button;
286 }
287
288 function fillCard(card, kind, runner) {
289 var root = card.root;
290 root.dataset.state = kind.state;
291 root.dataset.tone = kind.tone;
292 root.classList.toggle("off", !kind.enabled);
293 card.pill.dataset.tone = kind.tone;
294 card.pill.classList.toggle("live", !!BUSY[kind.state]);
295 // Said again only when it changes, so a screen reader hears each change once.
296 var text = card.pill.querySelector(".pill-text");
297 if (text.textContent !== kind.title) text.textContent = kind.title;
298 if (card.about.textContent !== kind.about) card.about.textContent = kind.about;
299
300 // What changes the buttons and the words is drawn again only when it changes,
301 // so a button keeps its focus while the status ticks.
302 var update = runner.diagnosis.update || null;
303 var signature = JSON.stringify([kind, runner.link, update && update.checked]);
304 if (signature === card.signature) return;
305 card.signature = signature;
306
307 var body = card.body;
308 body.textContent = "";
309 if (kind.job) body.appendChild(jobRow(kind.job));
310 if (kind.state === "downloading" && kind.progress != null) body.appendChild(progressRow(kind));
311 if (kind.headline) body.appendChild(element("p", "kind-headline", kind.headline));
312 if (kind.cause) body.appendChild(prose(element("p", "kind-cause"), kind.cause));
313 if (kind.fix) body.appendChild(prose(element("p", "kind-fix"), kind.fix));
314 body.hidden = !body.firstChild;
315
316 var details = card.details;
317 details.textContent = "";
318 // Its name on g1t opens the workspace's Runners page, where it is listed.
319 var named = null;
320 if (kind.runner && runner.link) {
321 named = element("button", "link", kind.runner);
322 named.type = "button";
323 named.setAttribute("aria-label", "View " + kind.runner + " on g1t");
324 named.appendChild(outward());
325 named.addEventListener("click", function () {
326 invoke("runner_control", { action: "view_runners" }).catch(fail);
327 });
328 }
329 detail(details, "On g1t", kind.runner, named);
330 detail(details, "Version", kind.version ? "g1t-runner " + kind.version : null);
331 // A newer version waiting for this kind of job, and when g1t last looked.
332 if (update && (kind.update || kind.version)) {
333 var updates = element("span", "kind-update");
334 if (kind.update) updates.appendChild(element("span", "kind-update-ready", kind.update));
335 updates.appendChild(element("span", "muted", update.checked));
336 detail(details, "Updates", true, updates);
337 }
338 detail(details, "Isolation", ISOLATION[kind.isolation] || kind.isolation);
339 details.hidden = !details.firstChild;
340
341 var actions = card.actions;
342 actions.textContent = "";
343 // Its first fix stands out when it is the person's to do; while g1t
344 // sees to it (waiting for the release's images), none does.
345 var urgent = kind.tone === "problem" || kind.tone === "warning";
346 kind.actions.forEach(function (action, at) {
347 actions.appendChild(actionButton(action, at === 0 && urgent && kind.state !== "waiting", kind.id, kindStatus(kind, runner)));
348 });
349 if (kind.can_toggle) {
350 var toggle = toggleButton(kind, kind.actions.length === 0, runner);
351 toggle.classList.add("end");
352 actions.appendChild(toggle);
353 }
354 actions.hidden = !actions.firstChild;
355 }
356
357 function renderKinds(runner) {
358 var list = byId("runner-kinds");
359 var kinds = (runner && runner.diagnosis.capabilities) || [];
360 list.hidden = kinds.length === 0;
361 var seen = {};
362 kinds.forEach(function (kind, at) {
363 seen[kind.id] = true;
364 var card = cards[kind.id] || (cards[kind.id] = makeCard(kind));
365 if (list.children[at] !== card.root) list.insertBefore(card.root, list.children[at] || null);
366 fillCard(card, kind, runner);
367 });
368 Object.keys(cards).forEach(function (id) {
369 if (seen[id]) return;
370 cards[id].root.remove();
371 delete cards[id];
372 });
373 list.classList.toggle("single", kinds.length === 1);
374 }
375
376 function renderRunner(runner) {
377 if (!runner) return;
378 var diagnosis = runner.diagnosis;
379 renderEngine(runner);
380 // The whole runner's line, when the cards don't already say it.
381 var cardsSayIt = diagnosis.in_capabilities && diagnosis.capabilities && diagnosis.capabilities.length > 0;
382 byId("runner-diagnosis").hidden = !!cardsSayIt;
383 byId("runner-dot").className = "dot " + diagnosis.tone;
384 byId("runner-title").textContent = diagnosis.title;
385 prose(byId("runner-cause"), diagnosis.cause);
386 prose(byId("runner-fix"), diagnosis.fix);
387 var actions = byId("runner-actions");
388 actions.textContent = "";
389 diagnosis.actions.forEach(function (action, at) {
390 actions.appendChild(actionButton(action, at === 0, "runner", runnerStatus(runner)));
391 });
392 renderKinds(runner);
393 byId("runner").checked = !!runner.enabled;
394
395 var backend = byId("runnerBackend");
396 backend.value = runner.backend;
397 var about = BACKENDS[runner.backend] || BACKENDS.sandboxed;
398 byId("runner-backend-note").textContent = about.text;
399 byId("runner-backend-note").className = about.warn ? "warning-note" : "muted";
400
401 var workspaces = byId("runnerWorkspace");
402 workspaces.textContent = "";
403 var known = runner.workspaces.slice();
404 var listed = known.some(function (w) {
405 return w.slug === runner.workspace;
406 });
407 if (runner.workspace && !listed) known.unshift({ slug: runner.workspace, name: null });
408 known.forEach(function (w) {
409 workspaces.appendChild(option(w.slug, w.name ? w.name + " (" + w.slug + ")" : w.slug));
410 });
411 if (known.length === 0) workspaces.appendChild(option("", "Open g1t, signed in, to choose"));
412 workspaces.value = runner.workspace || "";
413 workspaces.disabled = known.length === 0;
414
415 byId("runner-machine").textContent = "This computer: " + runner.machine;
416 // The runner's version, when g1t last looked for a newer one, and, with
417 // no cards to say it, what becomes of one waiting.
418 var update = diagnosis.update;
419 var about = [runner.version ? "g1t-runner " + runner.version : null, update ? update.checked : null, update && update.note].filter(Boolean);
420 byId("runner-version").textContent = about.length ? about.join(" · ") : " ";
421 var check = byId("runner-check");
422 check.hidden = !update;
423 if (update) {
424 check.textContent = update.check.label;
425 // Busy while the app looks, whoever asked, and from a click until it says it is looking.
426 if (update.checking || busy.pressed("check", runnerStatus(runner))) busy.start(check);
427 else busy.stop(check);
428 }
429 // A Start or Stop pressed is done once the status says so.
430 if (!busy.pressed("engine", runnerStatus(runner))) busy.stop(byId("engine-toggle"));
431 byId("runner-view").hidden = !runner.link;
432 // The log follows along, unless it was scrolled back to read.
433 var log = byId("runner-log");
434 var following = log.scrollTop + log.clientHeight >= log.scrollHeight - 8;
435 log.textContent = runner.log.length ? runner.log.join("\n") : "Nothing yet.";
436 if (following) log.scrollTop = log.scrollHeight;
437 }
438
439 byId("runnerBackend").addEventListener("change", save);
440 byId("runnerWorkspace").addEventListener("change", save);
441 // A button that asks the runner for something: busy until the status
442 // that follows when it `waits`, or until the app has it.
443 function control(id, key, action, waits) {
444 var button = byId(id);
445 button.addEventListener("click", function () {
446 var status = current && current.runnerView ? runnerStatus(current.runnerView) : "";
447 busy.run(
448 button,
449 key,
450 status,
451 waits,
452 function () {
453 return invoke("runner_control", { action: typeof action === "function" ? action() : action });
454 },
455 fail,
456 );
457 });
458 }
459 control("runner-view", "view", "view_runners", false);
460 control("runner-open-log", "log", "view_log", false);
461 // Looks now; what it finds installs as each kind of job is idle, and the status that follows says so.
462 control("runner-check", "check", "check_updates", true);
463
464 // ── The whole runner, at the top ─────────────────────────────────────
465 // As the title bar's chip says it, with what it is doing in a line and
466 // its Start or Stop; the cards below say each kind of job.
467 function renderEngine(runner) {
468 byId("engine-dot").className = "dot " + runner.chip.tone;
469 var label = byId("engine-label");
470 if (label.textContent !== runner.chip.label) label.textContent = runner.chip.label;
471 var about = [runner.diagnosis.title, runner.version ? "g1t-runner " + runner.version : null, runner.workspace ? "for " + runner.workspace : null].filter(
472 Boolean,
473 );
474 byId("engine-sub").textContent = about.join(" · ");
475 var toggle = byId("engine-toggle");
476 var on = runner.enabled && !runner.paused;
477 toggle.hidden = false;
478 toggle.textContent = on ? "Stop" : "Start";
479 toggle.className = on ? "quiet" : "primary";
480 toggle.dataset.action = on ? "stop" : "start";
481 }
482
483 control(
484 "engine-toggle",
485 "engine",
486 function () {
487 return byId("engine-toggle").dataset.action;
488 },
489 true,
490 );
491
492 // Every setting, as the app has it now (the settings window changes the
493 // rest), with the runner's own as they are on the page.
494 var KEPT = [
495 "startHidden",
496 "closeToTray",
497 "notifications",
498 "flash",
499 "badge",
500 "systemTitleBar",
501 "autoUpdate",
502 "zoom",
503 "developerTools",
504 "runner",
505 "runnerBackend",
506 "runnerWorkspace",
507 "runnerCapabilities",
508 ];
509 function save() {
510 invoke("get_settings")
511 .then(function (fresh) {
512 var settings = {};
513 KEPT.forEach(function (key) {
514 if (key in fresh) settings[key] = fresh[key];
515 });
516 settings.runner = byId("runner").checked;
517 settings.runnerBackend = byId("runnerBackend").value || "sandboxed";
518 settings.runnerWorkspace = byId("runnerWorkspace").value || null;
519 return invoke("save_settings", { settings: settings });
520 })
521 .then(
522 function (view) {
523 current = view;
524 renderRunner(view.runnerView);
525 },
526 function (error) {
527 fail(error);
528 if (current) renderRunner(current.runnerView);
529 },
530 );
531 }
532 byId("runner").addEventListener("change", save);
533
534 var events = window.__TAURI__ && window.__TAURI__.event;
535 if (events && typeof events.listen === "function") {
536 events
537 .listen("g1t-runner-status", function (event) {
538 if (current) current.runnerView = event.payload;
539 renderRunner(event.payload);
540 })
541 .catch(function () {});
542 }
543
544 // Links out, in the system's browser (src/bridge.js does the same on the site).
545 document.addEventListener("click", function (event) {
546 var anchor = event.target && event.target.closest ? event.target.closest("a[href]") : null;
547 if (!anchor) return;
548 event.preventDefault();
549 invoke("open_url", { url: anchor.href }).catch(function () {});
550 });
551
552 // g1t's own title bar, when the window has no system frame.
553 function frame(view) {
554 if (!view.customFrame) return;
555 if (view.platform === "macos") document.documentElement.classList.add("mac");
556 var bar = byId("titlebar");
557 bar.hidden = false;
558 document.body.classList.add("framed");
559 bar.addEventListener("mousedown", function (event) {
560 if (event.button !== 0 || event.target.closest("button")) return;
561 invoke("window_control", { action: "drag" }).catch(function () {});
562 });
563 byId("close-window").addEventListener("click", function () {
564 invoke("window_control", { action: "close" }).catch(function () {});
565 });
566 }
567
568 invoke("get_settings")
569 .then(function (view) {
570 current = view;
571 frame(view);
572 renderRunner(view.runnerView);
573 var log = byId("runner-log");
574 log.scrollTop = log.scrollHeight;
575 })
576 .catch(fail);
577})();