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