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.
32 files+1928−2650/32 viewed
| 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 | + | })(); |
| 1 | 1 | <!doctype html> | |
| 2 | 2 | <!-- | |
| 3 | 3 | 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 | |
| 6 | 8 | runs and the job each is running, how jobs run and for which workspace, | |
| 7 | 9 | its updates and its log, live (runner.js). Its words are the app's | |
| 8 | 10 | (src/runner/diagnosis.rs). | |
| ⋯ | |||
| 101 | 103 | <p class="muted small foot"><a href="https://docs.g1t.sh/guides/desktop/#the-runner">About the runner</a></p> | |
| 102 | 104 | </main> | |
| 103 | 105 | </div> | |
| 106 | + | <script src="busy.js"></script> | |
| 104 | 107 | <script src="runner.js"></script> | |
| 105 | 108 | </body> | |
| 106 | 109 | </html> | |
| 44 | 44 | return item; | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 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) { | |
| 49 | 67 | var button = document.createElement("button"); | |
| 50 | 68 | button.type = "button"; | |
| 51 | 69 | button.textContent = action.label; | |
| 52 | 70 | if (primary) button.className = "primary"; | |
| 71 | + | key = key + ":" + action.id; | |
| 72 | + | if (busy.pressed(key, status)) busy.start(button); | |
| 53 | 73 | button.addEventListener("click", function () { | |
| 54 | 74 | if (action.id === "choose_workspace") return byId("runnerWorkspace").focus(); | |
| 55 | 75 | 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 | + | }); | |
| 74 | 86 | }); | |
| 75 | 87 | return button; | |
| 76 | 88 | } | |
| 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 | 89 | ||
| 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 | 90 | function element(tag, className, text) { | |
| 100 | 91 | var item = document.createElement(tag); | |
| 101 | 92 | if (className) item.className = className; | |
| ⋯ | |||
| 133 | 124 | 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 | 125 | var BUSY = { busy: true, starting: true, downloading: true, updating: true }; | |
| 135 | 126 | var cards = {}; | |
| 136 | − | var pending = {}; | |
| 137 | 127 | ||
| 138 | 128 | // A small arrow: the link goes out to g1t. | |
| 139 | 129 | function outward() { | |
| ⋯ | |||
| 249 | 239 | list.appendChild(row); | |
| 250 | 240 | } | |
| 251 | 241 | ||
| 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) { | |
| 254 | 245 | var on = kind.enabled; | |
| 255 | − | var waiting = pending[kind.id]; | |
| 256 | 246 | var button = element("button", on ? "quiet" : onFirst ? "primary" : null); | |
| 257 | 247 | button.type = "button"; | |
| 258 | − | button.textContent = waiting ? (on ? "Stopping…" : "Starting…") : on ? "Stop" : "Start"; | |
| 248 | + | button.textContent = on ? "Stop" : "Start"; | |
| 259 | 249 | 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); | |
| 261 | 253 | 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); | |
| 273 | 257 | }); | |
| 274 | 258 | return button; | |
| 275 | 259 | } | |
| ⋯ | |||
| 289 | 273 | // What changes the buttons and the words is drawn again only when it changes, | |
| 290 | 274 | // so a button keeps its focus while the status ticks. | |
| 291 | 275 | 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]); | |
| 293 | 277 | if (signature === card.signature) return; | |
| 294 | 278 | card.signature = signature; | |
| 295 | 279 | ||
| ⋯ | |||
| 333 | 317 | // sees to it (waiting for the release's images), none does. | |
| 334 | 318 | var urgent = kind.tone === "problem" || kind.tone === "warning"; | |
| 335 | 319 | 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))); | |
| 337 | 321 | }); | |
| 338 | 322 | if (kind.can_toggle) { | |
| 339 | − | var toggle = toggleButton(kind, kind.actions.length === 0); | |
| 323 | + | var toggle = toggleButton(kind, kind.actions.length === 0, runner); | |
| 340 | 324 | toggle.classList.add("end"); | |
| 341 | 325 | actions.appendChild(toggle); | |
| 342 | 326 | } | |
| 343 | 327 | actions.hidden = !actions.firstChild; | |
| 344 | 328 | } | |
| 345 | 329 | ||
| 346 | − | var lastKinds = null; | |
| 347 | − | ||
| 348 | 330 | function renderKinds(runner) { | |
| 349 | − | lastKinds = runner; | |
| 350 | 331 | var list = byId("runner-kinds"); | |
| 351 | 332 | var kinds = (runner && runner.diagnosis.capabilities) || []; | |
| 352 | 333 | list.hidden = kinds.length === 0; | |
| ⋯ | |||
| 379 | 360 | var actions = byId("runner-actions"); | |
| 380 | 361 | actions.textContent = ""; | |
| 381 | 362 | diagnosis.actions.forEach(function (action, at) { | |
| 382 | − | actions.appendChild(actionButton(action, at === 0)); | |
| 363 | + | actions.appendChild(actionButton(action, at === 0, "runner", runnerStatus(runner))); | |
| 383 | 364 | }); | |
| 384 | 365 | renderKinds(runner); | |
| 385 | 366 | byId("runner").checked = !!runner.enabled; | |
| ⋯ | |||
| 414 | 395 | check.hidden = !update; | |
| 415 | 396 | if (update) { | |
| 416 | 397 | 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); | |
| 418 | 401 | } | |
| 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")); | |
| 419 | 404 | byId("runner-view").hidden = !runner.link; | |
| 420 | 405 | // The log follows along, unless it was scrolled back to read. | |
| 421 | 406 | var log = byId("runner-log"); | |
| ⋯ | |||
| 426 | 411 | ||
| 427 | 412 | byId("runnerBackend").addEventListener("change", save); | |
| 428 | 413 | 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); | |
| 441 | 423 | }); | |
| 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); | |
| 443 | 429 | ||
| 444 | 430 | // ── The whole runner, at the top ───────────────────────────────────── | |
| 445 | 431 | // As the title bar's chip says it, with what it is doing in a line and | |
| ⋯ | |||
| 458 | 444 | toggle.dataset.action = on ? "stop" : "start"; | |
| 459 | 445 | } | |
| 460 | 446 | ||
| 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); | |
| 470 | 450 | ||
| 471 | 451 | // Every setting, as the app has it now (the settings window changes the | |
| 472 | 452 | // rest), with the runner's own as they are on the page. | |
| ⋯ | |||
| 501 | 481 | events | |
| 502 | 482 | .listen("g1t-runner-status", function (event) { | |
| 503 | 483 | if (current) current.runnerView = event.payload; | |
| 504 | − | pending = {}; | |
| 505 | 484 | renderRunner(event.payload); | |
| 506 | 485 | }) | |
| 507 | 486 | .catch(function () {}); | |
| 129 | 129 | <p class="muted small foot"><a href="https://docs.g1t.sh/guides/desktop/">About the desktop app</a></p> | |
| 130 | 130 | </main> | |
| 131 | 131 | </div> | |
| 132 | + | <script src="busy.js"></script> | |
| 132 | 133 | <script src="settings.js"></script> | |
| 133 | 134 | </body> | |
| 134 | 135 | </html> |
| 1 | 1 | // The settings window's work: show what the app has, and keep each change | |
| 2 | 2 | // 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). | |
| 5 | 8 | (function () { | |
| 6 | 9 | var SWITCHES = ["startHidden", "closeToTray", "notifications", "flash", "badge", "systemTitleBar", "autoUpdate"]; | |
| 7 | 10 | // The settings the app keeps (src/settings.rs Settings); a save sends them all. | |
| ⋯ | |||
| 98 | 101 | } | |
| 99 | 102 | ||
| 100 | 103 | 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); | |
| 102 | 107 | }); | |
| 103 | 108 | ||
| 104 | 109 | SWITCHES.forEach(function (key) { | |
| ⋯ | |||
| 143 | 148 | checking = true; | |
| 144 | 149 | var button = byId("check"); | |
| 145 | 150 | var status = byId("update-status"); | |
| 146 | − | button.disabled = true; | |
| 151 | + | window.g1tBusy.start(button); | |
| 147 | 152 | status.textContent = "Checking, and downloading any new version…"; | |
| 148 | 153 | invoke("check_for_updates") | |
| 149 | 154 | .then(function (answer) { | |
| ⋯ | |||
| 158 | 163 | }) | |
| 159 | 164 | .then(function () { | |
| 160 | 165 | checking = false; | |
| 161 | − | button.disabled = false; | |
| 166 | + | window.g1tBusy.stop(button); | |
| 162 | 167 | }); | |
| 163 | 168 | } | |
| 164 | 169 | ||
| 165 | 170 | byId("check").addEventListener("click", check); | |
| 166 | 171 | byId("restart").addEventListener("click", function () { | |
| 167 | − | byId("restart").disabled = true; | |
| 172 | + | window.g1tBusy.start(byId("restart")); | |
| 168 | 173 | byId("update-status").textContent = "Installing…"; | |
| 169 | 174 | invoke("restart_to_update").catch(function (error) { | |
| 170 | − | byId("restart").disabled = false; | |
| 175 | + | window.g1tBusy.stop(byId("restart")); | |
| 171 | 176 | fail(error); | |
| 172 | 177 | }); | |
| 173 | 178 | }); | |
| ⋯ | |||
| 191 | 196 | } | |
| 192 | 197 | ||
| 193 | 198 | byId("restart-app").addEventListener("click", function () { | |
| 194 | − | byId("restart-app").disabled = true; | |
| 199 | + | window.g1tBusy.start(byId("restart-app")); | |
| 195 | 200 | invoke("restart").catch(function (error) { | |
| 196 | − | byId("restart-app").disabled = false; | |
| 201 | + | window.g1tBusy.stop(byId("restart-app")); | |
| 197 | 202 | fail(error); | |
| 198 | 203 | }); | |
| 199 | 204 | }); | |
| 200 | 205 | ||
| 201 | 206 | 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); | |
| 203 | 210 | }); | |
| 204 | 211 | ||
| 205 | 212 | // Links out, in the system's browser (src/bridge.js does the same on the site). | |
| 113 | 113 | outline: 2px solid var(--accent); | |
| 114 | 114 | outline-offset: -2px; | |
| 115 | 115 | } | |
| 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). */ | |
| 117 | 119 | .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; | |
| 122 | 124 | border-radius: 6px; | |
| 123 | 125 | white-space: nowrap; | |
| 124 | 126 | } | |
| 125 | 127 | .chip[hidden] { | |
| 126 | 128 | display: none; | |
| 127 | 129 | } | |
| 128 | − | .chip:hover { | |
| 130 | + | .chip:hover, | |
| 131 | + | .chip[aria-expanded="true"] { | |
| 129 | 132 | background: var(--hover); | |
| 130 | 133 | color: var(--fg); | |
| 131 | 134 | } | |
| 132 | 135 | .chip-dot { | |
| 133 | − | width: 7px; | |
| 134 | − | height: 7px; | |
| 136 | + | width: 8px; | |
| 137 | + | height: 8px; | |
| 138 | + | flex: none; | |
| 135 | 139 | border-radius: 50%; | |
| 136 | 140 | background: var(--muted); | |
| 137 | 141 | } | |
| ⋯ | |||
| 162 | 166 | animation: none; | |
| 163 | 167 | } | |
| 164 | 168 | } | |
| 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) { | |
| 167 | 175 | .chip-text { | |
| 168 | − | display: none; | |
| 176 | + | display: inline; | |
| 169 | 177 | } | |
| 170 | 178 | } | |
| 79 | 79 | }); | |
| 80 | 80 | left.append(back, forward); | |
| 81 | 81 | ||
| 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 })); | |
| 87 | 93 | }); | |
| 88 | 94 | chip.hidden = true; | |
| 95 | + | chip.setAttribute("aria-haspopup", "dialog"); | |
| 96 | + | chip.setAttribute("aria-expanded", "false"); | |
| 89 | 97 | var chipDot = document.createElement("span"); | |
| 90 | 98 | chipDot.className = "chip-dot"; | |
| 91 | 99 | var chipText = document.createElement("span"); | |
| ⋯ | |||
| 96 | 104 | chip.hidden = false; | |
| 97 | 105 | chip.dataset.tone = said.tone; | |
| 98 | 106 | 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; | |
| 101 | 109 | } | |
| 102 | 110 | // The app's settings, at the right of the bar, before the window's | |
| 103 | 111 | // buttons: where they are found without the menu. | |
| ⋯ | |||
| 188 | 196 | title.textContent = page.title || "g1t"; | |
| 189 | 197 | middle.append(title); | |
| 190 | 198 | } | |
| 199 | + | chip.setAttribute("aria-expanded", page.runnerMenu ? "true" : "false"); | |
| 191 | 200 | back.disabled = page.canGoBack === false; | |
| 192 | 201 | forward.disabled = page.canGoForward === false; | |
| 193 | 202 | setColors(page.colors); | |
| 28 | 28 | "runner_offer", | |
| 29 | 29 | "runner_register", | |
| 30 | 30 | "runner_control", | |
| 31 | + | "site_ready", | |
| 31 | 32 | ]))) | |
| 32 | 33 | .expect("the app's build could not be prepared"); | |
| 33 | 34 | } |
| 106 | 106 | checkForUpdates: function () { | |
| 107 | 107 | return invoke("check_for_updates"); | |
| 108 | 108 | }, | |
| 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. | |
| 110 | 111 | openSettings: function () { | |
| 111 | 112 | quietly(invoke("open_settings")); | |
| 112 | 113 | }, | |
| 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 | + | }), | |
| 113 | 163 | // The title bar's search, in its middle: the site hands it a label | |
| 114 | 164 | // ("Search Flagon") and what opens its search. Returns what takes it away. | |
| 115 | 165 | setSearch: function (label, open) { | |
| ⋯ | |||
| 224 | 274 | // ── The title bar ───────────────────────────────────────────────────── | |
| 225 | 275 | // What the bar above the page shows of it: the title, the search the | |
| 226 | 276 | // 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. | |
| 230 | 281 | var search = null; | |
| 282 | + | var runnerMenu = false; | |
| 231 | 283 | var report = function () {}; | |
| 232 | 284 | if (FRAME !== "custom") return; | |
| 233 | 285 | ||
| ⋯ | |||
| 285 | 337 | canGoBack: nav && typeof nav.canGoBack === "boolean" ? nav.canGoBack : null, | |
| 286 | 338 | canGoForward: nav && typeof nav.canGoForward === "boolean" ? nav.canGoForward : null, | |
| 287 | 339 | colors: colors(), | |
| 340 | + | runnerMenu: runnerMenu, | |
| 288 | 341 | }; | |
| 289 | 342 | var text = JSON.stringify(page); | |
| 290 | 343 | if (text === sent) return; | |
| 47 | 47 | if config::is_server(app, &url) { Ok(()) } else { Err("only g1t's own pages may ask that".into()) } | |
| 48 | 48 | } | |
| 49 | 49 | ||
| 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 | + | ||
| 50 | 57 | /// The page asking is the launcher. | |
| 51 | 58 | fn from_launcher(webview: &Webview) -> Result<(), String> { | |
| 52 | 59 | let url = caller(webview)?; | |
| ⋯ | |||
| 163 | 170 | } | |
| 164 | 171 | ||
| 165 | 172 | /// 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. | |
| 168 | 176 | #[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> { | |
| 170 | 178 | 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 | |
| 172 | 181 | } | |
| 173 | 182 | ||
| 174 | 183 | /// A title bar button: `minimize`, `maximize` (which toggles), `close`, | |
| ⋯ | |||
| 202 | 211 | Ok(frame::current(&app)) | |
| 203 | 212 | } | |
| 204 | 213 | ||
| 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. | |
| 206 | 217 | #[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> { | |
| 208 | 219 | from_bar(&webview)?; | |
| 209 | − | frame::act(&app, &action) | |
| 220 | + | frame::act(&app, &action, at) | |
| 210 | 221 | } | |
| 211 | 222 | ||
| 212 | 223 | /// Restarts g1t, installing a downloaded update on the way: the settings | |
| 213 | 224 | /// window offers it after a change that needs one. | |
| 214 | 225 | #[tauri::command] | |
| 215 | 226 | pub fn restart(app: AppHandle, webview: Webview) -> Result<(), String> { | |
| 216 | − | from_settings(&webview)?; | |
| 227 | + | from_settings(&webview).or_else(|_| from_site_page(&app, &webview))?; | |
| 217 | 228 | if updates::ready(&app).is_some() { | |
| 218 | 229 | updates::restart(&app); | |
| 219 | 230 | } | |
| ⋯ | |||
| 224 | 235 | /// The settings window's "Restart now": installs the downloaded update and starts it. | |
| 225 | 236 | #[tauri::command] | |
| 226 | 237 | 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))?; | |
| 228 | 239 | updates::restart(&app); | |
| 229 | 240 | Ok(()) | |
| 230 | 241 | } | |
| 231 | 242 | ||
| 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. | |
| 233 | 245 | #[tauri::command] | |
| 234 | 246 | pub fn open_settings(app: AppHandle, webview: Webview) -> Result<(), String> { | |
| 235 | 247 | from_either(&app, &webview)?; | |
| ⋯ | |||
| 237 | 249 | Ok(()) | |
| 238 | 250 | } | |
| 239 | 251 | ||
| 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. | |
| 241 | 255 | #[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] | |
| 242 | 264 | 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))?; | |
| 244 | 266 | Ok(settings::view(&app)) | |
| 245 | 267 | } | |
| 246 | 268 | ||
| 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. | |
| 248 | 276 | #[tauri::command] | |
| 249 | 277 | 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 | + | } | |
| 251 | 284 | settings::apply(&app, settings)?; | |
| 252 | 285 | Ok(settings::view(&app)) | |
| 253 | 286 | } | |
| 254 | 287 | ||
| 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 | + | ||
| 255 | 294 | /// Opening at sign-in, which the system keeps. | |
| 256 | 295 | #[tauri::command] | |
| 257 | 296 | 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))?; | |
| 259 | 298 | settings::open_at_login(&app, on)?; | |
| 260 | 299 | Ok(settings::view(&app)) | |
| 261 | 300 | } | |
| ⋯ | |||
| 263 | 302 | /// The settings window's "Change…": the main window goes to the launcher's choice of server. | |
| 264 | 303 | #[tauri::command] | |
| 265 | 304 | 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))?; | |
| 267 | 306 | if let Some(window) = app.get_webview_window(settings::SETTINGS) { | |
| 268 | 307 | let _ = window.close(); | |
| 269 | 308 | } | |
| ⋯ | |||
| 320 | 359 | assert!(super::script(false).contains(r#"var FRAME = "system";"#)); | |
| 321 | 360 | assert!(script.contains(r#"var OUTSIDE = ["/pricing""#)); | |
| 322 | 361 | } | |
| 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 | + | } | |
| 323 | 374 | } | |
| 38 | 38 | pub waiting: Mutex<i64>, | |
| 39 | 39 | /// The app's own settings, as the settings window last left them. | |
| 40 | 40 | 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>, | |
| 41 | 45 | } | |
| 42 | 46 | ||
| 43 | 47 | use crate::settings::Settings; | |
| ⋯ | |||
| 170 | 174 | "allow-runner-offer", | |
| 171 | 175 | "allow-runner-register", | |
| 172 | 176 | "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", | |
| 173 | 185 | ]; | |
| 174 | 186 | ||
| 175 | 187 | /// What GitHub's pages may call while someone signs in: telling the title bar their title. | |
| ⋯ | |||
| 202 | 214 | Ok(()) | |
| 203 | 215 | } | |
| 204 | 216 | ||
| 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() | |
| 208 | 234 | } | |
| 209 | 235 | ||
| 210 | 236 | /// Whether the window is on one of the server's pages now. | |
| 223 | 223 | pub can_go_back: Option<bool>, | |
| 224 | 224 | pub can_go_forward: Option<bool>, | |
| 225 | 225 | 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, | |
| 226 | 229 | } | |
| 227 | 230 | ||
| 228 | 231 | /// The page as last reported, kept for a bar that loads after it. | |
| ⋯ | |||
| 255 | 258 | } | |
| 256 | 259 | ||
| 257 | 260 | /// 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> { | |
| 261 | 265 | let site = site(app).ok_or("there is no page")?; | |
| 262 | 266 | match action { | |
| 263 | 267 | "back" => site.eval("history.back()").map_err(|error| error.to_string())?, | |
| 264 | 268 | "forward" => site.eval("history.forward()").map_err(|error| error.to_string())?, | |
| 265 | 269 | "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. | |
| 267 | 272 | "settings" => { | |
| 268 | 273 | crate::settings::open(app); | |
| 269 | 274 | return Ok(()); | |
| 270 | 275 | } | |
| 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. | |
| 272 | 277 | "runner" => { | |
| 273 | − | crate::settings::open_runner(app); | |
| 278 | + | crate::settings::runner_menu(app, true, at); | |
| 274 | 279 | return Ok(()); | |
| 275 | 280 | } | |
| 276 | 281 | "focus" => {} | |
| 25 | 25 | mod tray; | |
| 26 | 26 | mod updates; | |
| 27 | 27 | ||
| 28 | − | use tauri::webview::WebviewBuilder; | |
| 28 | + | use tauri::webview::{PageLoadEvent, WebviewBuilder}; | |
| 29 | 29 | use tauri::{RunEvent, WebviewUrl, WindowEvent}; | |
| 30 | 30 | use tauri_plugin_window_state::{StateFlags, WindowExt}; | |
| 31 | 31 | ||
| ⋯ | |||
| 87 | 87 | bridge::runner_offer, | |
| 88 | 88 | bridge::runner_register, | |
| 89 | 89 | bridge::runner_control, | |
| 90 | + | bridge::site_ready, | |
| 90 | 91 | ]) | |
| 91 | 92 | .on_menu_event(|app, event| menu::handle(app, event.id().as_ref())) | |
| 92 | 93 | .setup(|app| { | |
| ⋯ | |||
| 106 | 107 | }) | |
| 107 | 108 | .on_page_load({ | |
| 108 | 109 | 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)) | |
| 110 | 111 | }); | |
| 111 | 112 | let builder = frame::main_window(app, custom)?; | |
| 112 | 113 | // macOS has one menu bar, the app's; elsewhere the menu is the window's own. | |
| 140 | 140 | match id { | |
| 141 | 141 | "open" => tray::show(app), | |
| 142 | 142 | "settings" => settings::open(app), | |
| 143 | − | "runner-status" => settings::open_runner(app), | |
| 143 | + | "runner-status" => settings::runner_menu(app, false, None), | |
| 144 | 144 | "runner-toggle" => crate::runner::toggle(app), | |
| 145 | 145 | "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. | |
| 147 | 147 | "check" => settings::open_and_check(app), | |
| 148 | 148 | "restart" => updates::restart(app), | |
| 149 | 149 | "quit" => updates::quit(app), |
| 94 | 94 | pub struct Action { | |
| 95 | 95 | pub id: &'static str, | |
| 96 | 96 | 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>, | |
| 97 | 105 | } | |
| 98 | 106 | ||
| 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 | + | ||
| 99 | 123 | #[derive(Clone, Debug, PartialEq, Eq, Serialize)] | |
| 100 | 124 | pub struct Diagnosis { | |
| 101 | 125 | pub code: Code, | |
| ⋯ | |||
| 307 | 331 | } | |
| 308 | 332 | } | |
| 309 | 333 | ||
| 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"); | |
| 314 | 338 | /// 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"); | |
| 317 | 341 | /// Removes the sandbox this computer has (`sandbox down --delete`, in the | |
| 318 | 342 | /// app's own folder for it) and starts the app's own: what the command a | |
| 319 | 343 | /// 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"); | |
| 336 | 363 | ||
| 337 | 364 | fn some(text: impl Into<String>) -> Option<String> { | |
| 338 | 365 | Some(text.into()) | |
| ⋯ | |||
| 897 | 924 | } | |
| 898 | 925 | ||
| 899 | 926 | #[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] | |
| 900 | 942 | fn every_failure_says_why_and_how_to_fix_it() { | |
| 901 | 943 | for code in [ | |
| 902 | 944 | Code::SignedOut, | |
| 582 | 582 | Some(workspace) => crate::links::go(app, &format!("/{workspace}/-/runners")), | |
| 583 | 583 | None => crate::links::go(app, "/"), | |
| 584 | 584 | }, | |
| 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), | |
| 587 | 591 | "view_log" => { | |
| 588 | 592 | let path = log_file(app); | |
| 589 | 593 | if !path.exists() { |
| 1 | 1 | //! The app's own settings: how it starts, what closing the window does, | |
| 2 | 2 | //! 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. | |
| 8 | 12 | ||
| 9 | 13 | use serde::{Deserialize, Serialize}; | |
| 10 | − | use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; | |
| 14 | + | use tauri::{AppHandle, Emitter, EventTarget, Manager, WebviewUrl, WebviewWindowBuilder}; | |
| 11 | 15 | use tauri_plugin_autostart::ManagerExt; | |
| 12 | 16 | ||
| 13 | 17 | use crate::config; | |
| 14 | 18 | ||
| 15 | − | /// The settings window's label. | |
| 19 | + | /// The settings window's label: the settings when the site can't draw them. | |
| 16 | 20 | 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. | |
| 20 | 23 | pub const RUNNER: &str = "runner"; | |
| 21 | 24 | ||
| 22 | 25 | /// The argument the system starts g1t with at sign-in, so it can tell a | |
| ⋯ | |||
| 209 | 212 | pub const CHECK_EVENT: &str = "g1t-check-updates"; | |
| 210 | 213 | /// What it listens for: an update is downloaded, with its version. | |
| 211 | 214 | 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 | + | } | |
| 212 | 245 | ||
| 213 | − | /// Opens the settings window, or brings it forward. | |
| 246 | + | /// Opens the settings, or brings them forward. | |
| 214 | 247 | pub fn open(app: &AppHandle) { | |
| 215 | − | show(app, false); | |
| 248 | + | open_at(app, None, false); | |
| 216 | 249 | } | |
| 217 | 250 | ||
| 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. | |
| 220 | 253 | 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); | |
| 222 | 276 | } | |
| 223 | 277 | ||
| 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 | + | ||
| 224 | 292 | /// Opens the Runner window, or brings it forward. | |
| 225 | − | pub fn open_runner(app: &AppHandle) { | |
| 293 | + | pub fn open_runner_window(app: &AppHandle) { | |
| 226 | 294 | if !forward(app, RUNNER) { | |
| 227 | 295 | made_aside(app, Window { label: RUNNER, page: "runner.html", title: "g1t runner", size: (720.0, 760.0) }); | |
| 228 | 296 | } | |
| 1 | 1 | //! The tray icon and its menu, and showing and hiding the window. Closing | |
| 2 | 2 | //! the window hides it, unless settings say closing quits; g1t keeps | |
| 3 | 3 | //! 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). | |
| 7 | 8 | ||
| 8 | 9 | use std::sync::Mutex; | |
| 9 | 10 |
| 120 | 120 | *app.state::<config::State>().update.lock().unwrap() = Some(Ready { version: version.clone(), update, bytes }); | |
| 121 | 121 | tray::update_ready(&version); | |
| 122 | 122 | 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()); | |
| 123 | 125 | if !at_start { | |
| 124 | 126 | tell(app, "g1t is ready to update", &format!("Version {version} is downloaded. Restart g1t from the tray to use it.")); | |
| 125 | 127 | } |
| 84 | 84 | ||
| 85 | 85 | ### The title bar | |
| 86 | 86 | ||
| 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. | |
| 90 | 90 | ||
| 91 | 91 | | Part | What it does | | |
| 92 | 92 | | --- | --- | | |
| 93 | 93 | | **☰** (Windows and Linux) | Opens the app's [menu](#the-menu). | | |
| 94 | 94 | | **← →** | Back and forward through the pages you have been on. | | |
| 95 | 95 | | **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. | | |
| 98 | 98 | | **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. | | |
| 99 | 99 | ||
| 100 | 100 | Drag the bar to move the window, and double-click it to maximize or | |
| ⋯ | |||
| 103 | 103 | ||
| 104 | 104 | To have the system's title bar instead, turn on **Use the system's title | |
| 105 | 105 | 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 | |
| 107 | 107 | **Restart now**. | |
| 108 | 108 | ||
| 109 | 109 | ### The menu | |
| ⋯ | |||
| 124 | 124 | ||
| 125 | 125 | ## Settings | |
| 126 | 126 | ||
| 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). | |
| 133 | 138 | ||
| 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 | + | ||
| 134 | 144 | | Setting | Default | What it does | | |
| 135 | 145 | | --- | --- | --- | | |
| 136 | 146 | | **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. | | |
| ⋯ | |||
| 143 | 153 | | **Zoom** | 100% | How large g1t is drawn, from 67% to 200%. The View menu changes it too. | | |
| 144 | 154 | | **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. | | |
| 145 | 155 | ||
| 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. | |
| 150 | 167 | ||
| 151 | 168 | ## The runner | |
| 152 | 169 | ||
| ⋯ | |||
| 157 | 174 | opens, and stops when g1t quits. Time on it is free, as on any of your | |
| 158 | 175 | runners. | |
| 159 | 176 | ||
| 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). | |
| 164 | 181 | ||
| 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** | |
| 166 | 184 | and **Start**. Stopping Windows jobs leaves Linux jobs running, and the | |
| 167 | 185 | other way round; a kind you stop stays stopped, across restarts, until you | |
| 168 | 186 | start it again. Both are on until you stop one. | |
| ⋯ | |||
| 176 | 194 | `-windows`: `build-box-linux` has `linux`, and `build-box-windows` has | |
| 177 | 195 | `windows`, on a Windows computer with Windows Sandbox. | |
| 178 | 196 | ||
| 179 | − | ### The Runner window | |
| 197 | + | ### The runner menu | |
| 180 | 198 | ||
| 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: | |
| 186 | 202 | ||
| 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 | + | ||
| 187 | 224 | | Setting | Default | What it does | | |
| 188 | 225 | | --- | --- | --- | | |
| 189 | 226 | | **Run jobs on this computer while g1t is open** | On | Starts the runner a few seconds after the window. Off, nothing runs. | | |
| ⋯ | |||
| 191 | 228 | | **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. | | |
| 192 | 229 | | **Workspace** | The one you were in when it first registered | The workspace whose jobs the runner runs. Lists the workspaces you own. | | |
| 193 | 230 | ||
| 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 | + | ||
| 194 | 237 | [Seeing it](#seeing-it) says what else it shows. | |
| 195 | 238 | ||
| 196 | 239 | ### How jobs run | |
| 197 | 240 | ||
| 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 | |
| 199 | 242 | reach. The app never changes it by itself: when the one you chose cannot | |
| 200 | 243 | start, it says why and runs nothing. | |
| 201 | 244 | ||
| ⋯ | |||
| 272 | 315 | from its registration, signed in or not. | |
| 273 | 316 | ||
| 274 | 317 | 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 | |
| 276 | 319 | run, waits for a job that is running to end, then removes the old | |
| 277 | 320 | registration and makes a new one. The sandbox is registered with one | |
| 278 | 321 | workspace, so moving it to another removes it and makes it again, image | |
| ⋯ | |||
| 302 | 345 | jobs and starts on the new one. | |
| 303 | 346 | ||
| 304 | 347 | 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 | |
| 307 | 350 | update that fails leaves the runner as it was, and the next hour's look | |
| 308 | 351 | tries again. Turning off **Download updates by themselves** stops the | |
| 309 | 352 | hourly look; **Check for runner updates** still looks, and installs. | |
| ⋯ | |||
| 312 | 355 | ||
| 313 | 356 | | Where | What it shows | | |
| 314 | 357 | | --- | --- | | |
| 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. | | |
| 318 | 362 | | 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. | | |
| 319 | 363 | ||
| 320 | 364 | ### When it isn't running | |
| ⋯ | |||
| 391 | 435 | ||
| 392 | 436 | To see where your copy stands, choose **Check for updates…** in the Help | |
| 393 | 437 | 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, | |
| 395 | 439 | which checks and says the answer there: | |
| 396 | 440 | ||
| 397 | 441 | - **Up to date. Last checked today at 10:42.** You have the newest version. | |
| 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 | + | } |
| 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 | + | } |
| 4 | 4 | ||
| 5 | 5 | import type { DesktopBackend, DesktopDiagnosis, DesktopRunner, DesktopUpdate, Runner } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 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"; | |
| 8 | 9 | import { EmptyState, TimeAgo } from "./ui"; | |
| 10 | + | import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "./ui/alert-dialog"; | |
| 9 | 11 | import { Badge, type BadgeTone } from "./ui/badge"; | |
| 10 | 12 | import { Button } from "./ui/button"; | |
| 11 | 13 | import { Card } from "./ui/card"; | |
| ⋯ | |||
| 13 | 15 | import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; | |
| 14 | 16 | ||
| 15 | 17 | /** A diagnosis's tone, as a dot. */ | |
| 16 | − | const DOT: Record<DesktopDiagnosis["tone"], string> = { | |
| 18 | + | export const DOT: Record<DesktopDiagnosis["tone"], string> = { | |
| 17 | 19 | ok: "bg-success", | |
| 18 | 20 | working: "bg-info", | |
| 19 | 21 | off: "bg-faint", | |
| ⋯ | |||
| 22 | 24 | }; | |
| 23 | 25 | ||
| 24 | 26 | /** 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> = { | |
| 26 | 28 | ok: "success", | |
| 27 | 29 | working: "info", | |
| 28 | 30 | off: "neutral", | |
| ⋯ | |||
| 31 | 33 | }; | |
| 32 | 34 | ||
| 33 | 35 | /** 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> = { | |
| 35 | 37 | linux: <SquareTerminal size={15} aria-hidden />, | |
| 36 | 38 | windows: <AppWindow size={15} aria-hidden />, | |
| 37 | 39 | macos: <Laptop size={15} aria-hidden />, | |
| ⋯ | |||
| 61 | 63 | update: DesktopUpdate | null; | |
| 62 | 64 | /** Here only: the app's button that looks for a newer runner now. */ | |
| 63 | 65 | check: DesktopRunnerAction | null; | |
| 66 | + | /** Here only: the app is looking for a newer runner now. */ | |
| 67 | + | checking: boolean; | |
| 64 | 68 | }; | |
| 65 | 69 | ||
| 66 | 70 | /** The desktops to list: this computer, live, first; then the others, as their apps last said, or closed. */ | |
| ⋯ | |||
| 80 | 84 | here: true, | |
| 81 | 85 | reportedAt: null, | |
| 82 | 86 | 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, | |
| 84 | 89 | }); | |
| 85 | 90 | } | |
| 86 | 91 | for (const desktop of desktops) { | |
| ⋯ | |||
| 99 | 104 | reportedAt: desktop.reportedAt, | |
| 100 | 105 | update: desktop.open ? (desktop.diagnosis.update ?? null) : null, | |
| 101 | 106 | check: null, | |
| 107 | + | checking: false, | |
| 102 | 108 | }); | |
| 103 | 109 | } | |
| 104 | 110 | return rows; | |
| 105 | 111 | } | |
| 106 | 112 | ||
| 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 | + | }) { | |
| 109 | 133 | if (actions.length === 0) return null; | |
| 110 | 134 | return ( | |
| 111 | 135 | <> | |
| 112 | 136 | {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} /> | |
| 116 | 138 | ))} | |
| 117 | 139 | </> | |
| 118 | 140 | ); | |
| 119 | 141 | } | |
| 120 | 142 | ||
| 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); | |
| 124 | 191 | const on = capability.enabled !== false; | |
| 125 | − | const waiting = asked !== null && asked !== on; | |
| 126 | 192 | return ( | |
| 127 | 193 | <Button | |
| 128 | 194 | size="xs" | |
| 129 | 195 | variant={on ? "outline" : "soft"} | |
| 130 | 196 | className="ml-auto" | |
| 131 | − | disabled={waiting} | |
| 197 | + | busy={busy} | |
| 132 | 198 | 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))} | |
| 139 | 200 | > | |
| 140 | − | {waiting ? (on ? "Stopping…" : "Starting…") : on ? "Stop" : "Start"} | |
| 201 | + | {on ? "Stop" : "Start"} | |
| 141 | 202 | </Button> | |
| 142 | 203 | ); | |
| 143 | 204 | } | |
| 144 | 205 | ||
| 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 | + | ||
| 145 | 232 | function Detail({ term, children }: { term: ReactNode; children: ReactNode }) { | |
| 146 | 233 | return ( | |
| 147 | 234 | <div className="flex min-w-0 items-baseline gap-3"> | |
| ⋯ | |||
| 162 | 249 | } | |
| 163 | 250 | ||
| 164 | 251 | /** 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 | + | }) { | |
| 166 | 268 | const href = jobHref(capability, runners); | |
| 167 | 269 | const isolation = capability.isolation ? (ISOLATION[capability.isolation] ?? { label: capability.isolation, hint: null }) : null; | |
| 168 | 270 | const edge = capability.tone === "problem" ? "border-danger/40" : capability.tone === "warning" ? "border-warn/35" : ""; | |
| ⋯ | |||
| 240 | 342 | )} | |
| 241 | 343 | {(fixes.length > 0 || toggle) && ( | |
| 242 | 344 | <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} />} | |
| 245 | 352 | </div> | |
| 246 | 353 | )} | |
| 247 | 354 | </Card> | |
| ⋯ | |||
| 299 | 406 | )} | |
| 300 | 407 | {row.check && ( | |
| 301 | 408 | <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} /> | |
| 303 | 410 | </div> | |
| 304 | 411 | )} | |
| 305 | 412 | </div> | |
| ⋯ | |||
| 313 | 420 | {row.diagnosis.fix && <p className="mt-0.5 text-sm text-fg-soft">{row.diagnosis.fix}</p>} | |
| 314 | 421 | {row.actions.length > 0 && ( | |
| 315 | 422 | <div className="mt-2 flex flex-wrap gap-2"> | |
| 316 | − | <Actions actions={row.actions} /> | |
| 423 | + | <Actions actions={row.actions} status={here ? runnerStatusKey(here) : ""} /> | |
| 317 | 424 | </div> | |
| 318 | 425 | )} | |
| 319 | 426 | </div> | |
| ⋯ | |||
| 322 | 429 | {row.capabilities.length > 0 && ( | |
| 323 | 430 | <div className={`grid gap-3 ${row.capabilities.length > 1 ? "sm:grid-cols-2" : ""}`}> | |
| 324 | 431 | {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} /> | |
| 326 | 433 | ))} | |
| 327 | 434 | </div> | |
| 328 | 435 | )} | |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Comments
Sign in to comment.