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
543 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 // A fix's button. Choosing is done here, in this window; a copy says it was copied.
48 function actionButton(action, primary) {
49 var button = document.createElement("button");
50 button.type = "button";
51 button.textContent = action.label;
52 if (primary) button.className = "primary";
53 button.addEventListener("click", function () {
54 if (action.id === "choose_workspace") return byId("runnerWorkspace").focus();
55 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 );
74 });
75 return button;
76 }
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.";
80
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
99 function element(tag, className, text) {
100 var item = document.createElement(tag);
101 if (className) item.className = className;
102 if (text != null) item.textContent = text;
103 return item;
104 }
105
106 // The catalogue's sentences name commands in backticks: `wsl --install`.
107 // Said here as code, which can be selected and copied.
108 function prose(into, text) {
109 into.textContent = "";
110 String(text || "")
111 .split("`")
112 .forEach(function (part, at) {
113 if (!part) return;
114 into.appendChild(at % 2 ? element("code", null, part) : document.createTextNode(part));
115 });
116 return into;
117 }
118
119 // ── Each kind of job, a card of its own ───────────────────────────────
120 // Linux jobs and Windows jobs (macOS jobs once they are released): what
121 // each is doing, why one isn't running and its fix, and its own Start or
122 // Stop, which leaves the other as it is. The words are the app's
123 // (CapabilityLine in src/runner/diagnosis.rs); the card only lays them out.
124 var SVG = "http://www.w3.org/2000/svg";
125 // Plain line icons, 16px on a 24px grid: a terminal for Linux jobs, a
126 // window for Windows jobs, a laptop for macOS jobs.
127 var ICONS = {
128 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"],
129 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"],
130 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"],
131 };
132 // How each runner keeps a job from this computer, said plainly.
133 var ISOLATION = { "nested-container": "A fresh container in the VM", vm: "A VM of its own", container: "A container of its own", host: "None" };
134 var BUSY = { busy: true, starting: true, downloading: true, updating: true };
135 var cards = {};
136 var pending = {};
137
138 // A small arrow: the link goes out to g1t.
139 function outward() {
140 var svg = document.createElementNS(SVG, "svg");
141 svg.setAttribute("viewBox", "0 0 24 24");
142 svg.setAttribute("width", "11");
143 svg.setAttribute("height", "11");
144 svg.setAttribute("fill", "none");
145 svg.setAttribute("stroke", "currentColor");
146 svg.setAttribute("stroke-width", "2");
147 svg.setAttribute("stroke-linecap", "round");
148 svg.setAttribute("aria-hidden", "true");
149 var path = document.createElementNS(SVG, "path");
150 path.setAttribute("d", "M7 17L17 7M9 7h8v8");
151 svg.appendChild(path);
152 return svg;
153 }
154
155 function icon(id) {
156 var svg = document.createElementNS(SVG, "svg");
157 svg.setAttribute("viewBox", "0 0 24 24");
158 svg.setAttribute("width", "16");
159 svg.setAttribute("height", "16");
160 svg.setAttribute("fill", "none");
161 svg.setAttribute("stroke", "currentColor");
162 svg.setAttribute("stroke-width", "1.75");
163 svg.setAttribute("stroke-linecap", "round");
164 svg.setAttribute("stroke-linejoin", "round");
165 svg.setAttribute("aria-hidden", "true");
166 (ICONS[id] || ICONS.linux).forEach(function (d) {
167 var path = document.createElementNS(SVG, "path");
168 path.setAttribute("d", d);
169 svg.appendChild(path);
170 });
171 return svg;
172 }
173
174 // A card's parts, made once and filled in on every status.
175 function makeCard(kind) {
176 var card = element("article", "kind");
177 card.setAttribute("role", "listitem");
178 card.setAttribute("aria-labelledby", "kind-" + kind.id + "-title");
179 var head = element("header", "kind-head");
180 var badge = element("span", "kind-icon");
181 badge.appendChild(icon(kind.id));
182 var name = element("span", "kind-name");
183 var title = element("h3", null, kind.label);
184 title.id = "kind-" + kind.id + "-title";
185 name.appendChild(title);
186 var pill = element("span", "pill");
187 // Its state is said when it changes, as "Linux jobs: Waiting for jobs".
188 pill.setAttribute("role", "status");
189 pill.setAttribute("aria-live", "polite");
190 pill.setAttribute("aria-atomic", "true");
191 var said = element("span", "visually-hidden", kind.label + ": ");
192 pill.appendChild(said);
193 pill.appendChild(element("span", "pill-dot"));
194 pill.appendChild(element("span", "pill-text"));
195 name.appendChild(pill);
196 head.appendChild(badge);
197 head.appendChild(name);
198 card.appendChild(head);
199 card.appendChild(element("p", "kind-about"));
200 var body = element("div", "kind-body");
201 card.appendChild(body);
202 var details = element("dl", "kind-details");
203 card.appendChild(details);
204 var actions = element("div", "kind-actions");
205 card.appendChild(actions);
206 return { root: card, pill: pill, about: card.querySelector(".kind-about"), body: body, details: details, actions: actions, signature: "" };
207 }
208
209 // What it is running, with where to see it on g1t.
210 function jobRow(job) {
211 var row = element("div", "kind-job");
212 row.appendChild(element("span", "kind-job-label", job.kind === "agent" ? "Agent work" : "Running"));
213 var what = element("span", "kind-job-what");
214 if (job.link) {
215 var link = element("a", null, job.name);
216 link.href = job.link;
217 what.appendChild(link);
218 } else {
219 what.appendChild(element("strong", null, job.name));
220 }
221 if (job.repo) what.appendChild(element("span", "muted", " in " + job.repo));
222 row.appendChild(what);
223 return row;
224 }
225
226 function progressRow(kind) {
227 var row = element("div", "kind-progress");
228 var bar = element("div", "bar");
229 bar.setAttribute("role", "progressbar");
230 bar.setAttribute("aria-valuemin", "0");
231 bar.setAttribute("aria-valuemax", "100");
232 bar.setAttribute("aria-valuenow", String(kind.progress));
233 bar.setAttribute("aria-label", kind.label + ": the sandbox's image");
234 var fill = element("span");
235 fill.style.width = Math.max(2, Math.min(100, kind.progress)) + "%";
236 bar.appendChild(fill);
237 row.appendChild(bar);
238 row.appendChild(element("span", "muted small", kind.progress + "%"));
239 return row;
240 }
241
242 function detail(list, term, value, given) {
243 if (!value) return;
244 var row = element("div");
245 row.appendChild(element("dt", null, term));
246 var dd = element("dd", null, given ? null : value);
247 if (given) dd.appendChild(given);
248 row.appendChild(dd);
249 list.appendChild(row);
250 }
251
252 // Start or Stop for one kind of job, said for which, and what it is doing meanwhile.
253 function toggleButton(kind, onFirst) {
254 var on = kind.enabled;
255 var waiting = pending[kind.id];
256 var button = element("button", on ? "quiet" : onFirst ? "primary" : null);
257 button.type = "button";
258 button.textContent = waiting ? (on ? "Stopping…" : "Starting…") : on ? "Stop" : "Start";
259 button.setAttribute("aria-label", (on ? "Stop " : "Start ") + kind.label);
260 button.disabled = !!waiting;
261 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 });
273 });
274 return button;
275 }
276
277 function fillCard(card, kind, runner) {
278 var root = card.root;
279 root.dataset.state = kind.state;
280 root.dataset.tone = kind.tone;
281 root.classList.toggle("off", !kind.enabled);
282 card.pill.dataset.tone = kind.tone;
283 card.pill.classList.toggle("live", !!BUSY[kind.state]);
284 // Said again only when it changes, so a screen reader hears each change once.
285 var text = card.pill.querySelector(".pill-text");
286 if (text.textContent !== kind.title) text.textContent = kind.title;
287 if (card.about.textContent !== kind.about) card.about.textContent = kind.about;
288
289 // What changes the buttons and the words is drawn again only when it changes,
290 // so a button keeps its focus while the status ticks.
291 var update = runner.diagnosis.update || null;
292 var signature = JSON.stringify([kind, !!pending[kind.id], runner.link, update && update.checked]);
293 if (signature === card.signature) return;
294 card.signature = signature;
295
296 var body = card.body;
297 body.textContent = "";
298 if (kind.job) body.appendChild(jobRow(kind.job));
299 if (kind.state === "downloading" && kind.progress != null) body.appendChild(progressRow(kind));
300 if (kind.headline) body.appendChild(element("p", "kind-headline", kind.headline));
301 if (kind.cause) body.appendChild(prose(element("p", "kind-cause"), kind.cause));
302 if (kind.fix) body.appendChild(prose(element("p", "kind-fix"), kind.fix));
303 body.hidden = !body.firstChild;
304
305 var details = card.details;
306 details.textContent = "";
307 // Its name on g1t opens the workspace's Runners page, where it is listed.
308 var named = null;
309 if (kind.runner && runner.link) {
310 named = element("button", "link", kind.runner);
311 named.type = "button";
312 named.setAttribute("aria-label", "View " + kind.runner + " on g1t");
313 named.appendChild(outward());
314 named.addEventListener("click", function () {
315 invoke("runner_control", { action: "view_runners" }).catch(fail);
316 });
317 }
318 detail(details, "On g1t", kind.runner, named);
319 detail(details, "Version", kind.version ? "g1t-runner " + kind.version : null);
320 // A newer version waiting for this kind of job, and when g1t last looked.
321 if (update && (kind.update || kind.version)) {
322 var updates = element("span", "kind-update");
323 if (kind.update) updates.appendChild(element("span", "kind-update-ready", kind.update));
324 updates.appendChild(element("span", "muted", update.checked));
325 detail(details, "Updates", true, updates);
326 }
327 detail(details, "Isolation", ISOLATION[kind.isolation] || kind.isolation);
328 details.hidden = !details.firstChild;
329
330 var actions = card.actions;
331 actions.textContent = "";
332 // Its first fix stands out when it is the person's to do; while g1t
333 // sees to it (waiting for the release's images), none does.
334 var urgent = kind.tone === "problem" || kind.tone === "warning";
335 kind.actions.forEach(function (action, at) {
336 actions.appendChild(actionButton(action, at === 0 && urgent && kind.state !== "waiting"));
337 });
338 if (kind.can_toggle) {
339 var toggle = toggleButton(kind, kind.actions.length === 0);
340 toggle.classList.add("end");
341 actions.appendChild(toggle);
342 }
343 actions.hidden = !actions.firstChild;
344 }
345
346 var lastKinds = null;
347
348 function renderKinds(runner) {
349 lastKinds = runner;
350 var list = byId("runner-kinds");
351 var kinds = (runner && runner.diagnosis.capabilities) || [];
352 list.hidden = kinds.length === 0;
353 var seen = {};
354 kinds.forEach(function (kind, at) {
355 seen[kind.id] = true;
356 var card = cards[kind.id] || (cards[kind.id] = makeCard(kind));
357 if (list.children[at] !== card.root) list.insertBefore(card.root, list.children[at] || null);
358 fillCard(card, kind, runner);
359 });
360 Object.keys(cards).forEach(function (id) {
361 if (seen[id]) return;
362 cards[id].root.remove();
363 delete cards[id];
364 });
365 list.classList.toggle("single", kinds.length === 1);
366 }
367
368 function renderRunner(runner) {
369 if (!runner) return;
370 var diagnosis = runner.diagnosis;
371 renderEngine(runner);
372 // The whole runner's line, when the cards don't already say it.
373 var cardsSayIt = diagnosis.in_capabilities && diagnosis.capabilities && diagnosis.capabilities.length > 0;
374 byId("runner-diagnosis").hidden = !!cardsSayIt;
375 byId("runner-dot").className = "dot " + diagnosis.tone;
376 byId("runner-title").textContent = diagnosis.title;
377 prose(byId("runner-cause"), diagnosis.cause);
378 prose(byId("runner-fix"), diagnosis.fix);
379 var actions = byId("runner-actions");
380 actions.textContent = "";
381 diagnosis.actions.forEach(function (action, at) {
382 actions.appendChild(actionButton(action, at === 0));
383 });
384 renderKinds(runner);
385 byId("runner").checked = !!runner.enabled;
386
387 var backend = byId("runnerBackend");
388 backend.value = runner.backend;
389 var about = BACKENDS[runner.backend] || BACKENDS.sandboxed;
390 byId("runner-backend-note").textContent = about.text;
391 byId("runner-backend-note").className = about.warn ? "warning-note" : "muted";
392
393 var workspaces = byId("runnerWorkspace");
394 workspaces.textContent = "";
395 var known = runner.workspaces.slice();
396 var listed = known.some(function (w) {
397 return w.slug === runner.workspace;
398 });
399 if (runner.workspace && !listed) known.unshift({ slug: runner.workspace, name: null });
400 known.forEach(function (w) {
401 workspaces.appendChild(option(w.slug, w.name ? w.name + " (" + w.slug + ")" : w.slug));
402 });
403 if (known.length === 0) workspaces.appendChild(option("", "Open g1t, signed in, to choose"));
404 workspaces.value = runner.workspace || "";
405 workspaces.disabled = known.length === 0;
406
407 byId("runner-machine").textContent = "This computer: " + runner.machine;
408 // The runner's version, when g1t last looked for a newer one, and, with
409 // no cards to say it, what becomes of one waiting.
410 var update = diagnosis.update;
411 var about = [runner.version ? "g1t-runner " + runner.version : null, update ? update.checked : null, update && update.note].filter(Boolean);
412 byId("runner-version").textContent = about.length ? about.join(" · ") : " ";
413 var check = byId("runner-check");
414 check.hidden = !update;
415 if (update) {
416 check.textContent = update.check.label;
417 check.disabled = update.checking;
418 }
419 byId("runner-view").hidden = !runner.link;
420 // The log follows along, unless it was scrolled back to read.
421 var log = byId("runner-log");
422 var following = log.scrollTop + log.clientHeight >= log.scrollHeight - 8;
423 log.textContent = runner.log.length ? runner.log.join("\n") : "Nothing yet.";
424 if (following) log.scrollTop = log.scrollHeight;
425 }
426
427 byId("runnerBackend").addEventListener("change", save);
428 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);
441 });
442 });
443
444 // ── The whole runner, at the top ─────────────────────────────────────
445 // As the title bar's chip says it, with what it is doing in a line and
446 // its Start or Stop; the cards below say each kind of job.
447 function renderEngine(runner) {
448 byId("engine-dot").className = "dot " + runner.chip.tone;
449 var label = byId("engine-label");
450 if (label.textContent !== runner.chip.label) label.textContent = runner.chip.label;
451 var about = [runner.diagnosis.title, runner.version ? "g1t-runner " + runner.version : null, runner.workspace ? "for " + runner.workspace : null].filter(Boolean);
452 byId("engine-sub").textContent = about.join(" · ");
453 var toggle = byId("engine-toggle");
454 var on = runner.enabled && !runner.paused;
455 toggle.hidden = false;
456 toggle.textContent = on ? "Stop" : "Start";
457 toggle.className = on ? "quiet" : "primary";
458 toggle.dataset.action = on ? "stop" : "start";
459 }
460
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 });
470
471 // Every setting, as the app has it now (the settings window changes the
472 // rest), with the runner's own as they are on the page.
473 var KEPT = ["startHidden", "closeToTray", "notifications", "flash", "badge", "systemTitleBar", "autoUpdate", "zoom", "runner", "runnerBackend", "runnerWorkspace", "runnerCapabilities"];
474 function save() {
475 invoke("get_settings")
476 .then(function (fresh) {
477 var settings = {};
478 KEPT.forEach(function (key) {
479 if (key in fresh) settings[key] = fresh[key];
480 });
481 settings.runner = byId("runner").checked;
482 settings.runnerBackend = byId("runnerBackend").value || "sandboxed";
483 settings.runnerWorkspace = byId("runnerWorkspace").value || null;
484 return invoke("save_settings", { settings: settings });
485 })
486 .then(
487 function (view) {
488 current = view;
489 renderRunner(view.runnerView);
490 },
491 function (error) {
492 fail(error);
493 if (current) renderRunner(current.runnerView);
494 },
495 );
496 }
497 byId("runner").addEventListener("change", save);
498
499 var events = window.__TAURI__ && window.__TAURI__.event;
500 if (events && typeof events.listen === "function") {
501 events
502 .listen("g1t-runner-status", function (event) {
503 if (current) current.runnerView = event.payload;
504 pending = {};
505 renderRunner(event.payload);
506 })
507 .catch(function () {});
508 }
509
510 // Links out, in the system's browser (src/bridge.js does the same on the site).
511 document.addEventListener("click", function (event) {
512 var anchor = event.target && event.target.closest ? event.target.closest("a[href]") : null;
513 if (!anchor) return;
514 event.preventDefault();
515 invoke("open_url", { url: anchor.href }).catch(function () {});
516 });
517
518 // g1t's own title bar, when the window has no system frame.
519 function frame(view) {
520 if (!view.customFrame) return;
521 if (view.platform === "macos") document.documentElement.classList.add("mac");
522 var bar = byId("titlebar");
523 bar.hidden = false;
524 document.body.classList.add("framed");
525 bar.addEventListener("mousedown", function (event) {
526 if (event.button !== 0 || event.target.closest("button")) return;
527 invoke("window_control", { action: "drag" }).catch(function () {});
528 });
529 byId("close-window").addEventListener("click", function () {
530 invoke("window_control", { action: "close" }).catch(function () {});
531 });
532 }
533
534 invoke("get_settings")
535 .then(function (view) {
536 current = view;
537 frame(view);
538 renderRunner(view.runnerView);
539 var log = byId("runner-log");
540 log.scrollTop = log.scrollHeight;
541 })
542 .catch(fail);
543})();