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