Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| 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. | 1 | import { ExternalLink } from "lucide-react"; |
| 2 | import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react"; | |
| 3 | ||
| 4 | import type { DesktopBackend } from "@g1t/contracts"; | |
| 5 | ||
| 6 | import { useBusy } from "../lib/busy"; | |
| 7 | import { type DesktopRunnerView, desktopRunner, runnerStatusKey } from "../lib/desktop-runner"; | |
| 8 | import { | |
| 9 | BACKEND_NOTE, | |
| 10 | type DesktopSettings, | |
| 11 | type DesktopSettingsView, | |
| 12 | SECTIONS, | |
| 13 | type SectionId, | |
| 14 | ZOOMS, | |
| 15 | checkFailed, | |
| 16 | desktopSettings, | |
| 17 | hostOf, | |
| 18 | platformWords, | |
| 19 | saveSetting, | |
| 20 | updateLine, | |
| 21 | } from "../lib/desktop-settings"; | |
| 22 | import { cn } from "../lib/cn"; | |
| 23 | import { ActButton, Actions, DOT, KindCard } from "./desktop-runners"; | |
| 24 | import { Button } from "./ui/button"; | |
| 25 | import { Card } from "./ui/card"; | |
| 26 | import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./ui/dialog"; | |
| 27 | import { SelectField } from "./ui/select"; | |
| 28 | import { SwitchCard } from "./ui/switch"; | |
| 29 | ||
| 30 | /** | |
| 31 | * The desktop app's settings, as a dialog over the site, the way a desktop | |
| 32 | * app's preferences are: its sections down the left, the one chosen on the | |
| 33 | * right, and Escape or the X to close. The app opens it (from the title | |
| 34 | * bar, its menu, the tray, a shortcut or the account menu) through | |
| 35 | * `DesktopApp`, at a section when it names one. Each change is kept the | |
| 36 | * moment it is made, over the settings as the app has them then | |
| 37 | * (lib/desktop-settings.ts); there is nothing to save. | |
| 38 | */ | |
| 39 | export function DesktopSettingsDialog({ | |
| 40 | open, | |
| 41 | section, | |
| 42 | check, | |
| 43 | onOpenChange, | |
| 44 | onSection, | |
| 45 | runner, | |
| 46 | }: { | |
| 47 | open: boolean; | |
| 48 | section: SectionId; | |
| 49 | /** Opened by "Check for updates…": it looks as it opens. Changes each time it is asked. */ | |
| 50 | check: number; | |
| 51 | onOpenChange: (open: boolean) => void; | |
| 52 | onSection: (section: SectionId) => void; | |
| 53 | runner: DesktopRunnerView | null; | |
| 54 | }) { | |
| 55 | const [view, setView] = useState<DesktopSettingsView | null>(null); | |
| 56 | const [error, setError] = useState<string | null>(null); | |
| 57 | const fail = useCallback((reason: unknown) => setError(reason instanceof Error ? reason.message : typeof reason === "string" ? reason : "That could not be changed."), []); | |
| 58 | ||
| 59 | // The settings as the app has them, each time it opens. | |
| 60 | useEffect(() => { | |
| 61 | if (!open) return; | |
| 62 | setError(null); | |
| 63 | desktopSettings() | |
| 64 | ?.get() | |
| 65 | .then(setView) | |
| 66 | .catch(fail); | |
| 67 | }, [open, fail]); | |
| 68 | ||
| 69 | /** One change, shown at once and kept over the app's settings as they are now. */ | |
| 70 | const change = (patch: Partial<DesktopSettings>) => { | |
| 71 | if (!view) return; | |
| 72 | const before = view; | |
| 73 | setView({ ...view, ...patch }); | |
| 74 | setError(null); | |
| 75 | saveSetting(patch).then(setView, (reason: unknown) => { | |
| 76 | setView(before); | |
| 77 | fail(reason); | |
| 78 | }); | |
| 79 | }; | |
| 80 | ||
| 81 | const checking = useChecking(view, setView, check); | |
| 82 | // A download finishing in the background, while the settings are open. | |
| 83 | useEffect(() => desktopSettings()?.onUpdateReady((version) => setView((current) => (current ? { ...current, updateReady: version } : current))), []); | |
| 84 | ||
| 85 | const live = runner ?? view?.runnerView ?? null; | |
| 86 | return ( | |
| 87 | <Dialog open={open} onOpenChange={onOpenChange}> | |
| 88 | <DialogContent | |
| 89 | className="flex h-[min(42rem,calc(100dvh-4rem))] max-w-4xl gap-0 overflow-hidden p-0 max-sm:h-[calc(100dvh-2rem)]" | |
| 90 | // Focus goes to the panel, not the first section in the list, which would look chosen. | |
| 91 | onOpenAutoFocus={(event) => { | |
| 92 | event.preventDefault(); | |
| 93 | (event.currentTarget as HTMLElement | null)?.focus(); | |
| 94 | }} | |
| 95 | > | |
| 96 | <nav aria-label="Settings" className="flex w-52 shrink-0 flex-col gap-0.5 border-r border-line bg-bg/40 p-3 max-sm:w-40"> | |
| 97 | <DialogTitle className="px-2 pt-1 pb-3">Settings</DialogTitle> | |
| 98 | {SECTIONS.map((item) => ( | |
| 99 | <Button | |
| 100 | key={item.id} | |
| 101 | variant="ghost" | |
| 102 | size="sm" | |
| 103 | className="w-full justify-start" | |
| 104 | aria-current={section === item.id ? "page" : undefined} | |
| 105 | aria-pressed={section === item.id} | |
| 106 | onClick={() => onSection(item.id)} | |
| 107 | > | |
| 108 | {item.label} | |
| 109 | {item.id === "runner" && live?.chip && <span className={cn("ml-auto size-2 rounded-full", DOT[live.chip.tone])} aria-hidden />} | |
| 110 | </Button> | |
| 111 | ))} | |
| 112 | </nav> | |
| 113 | <div className="@container min-w-0 grow overflow-y-auto overscroll-contain p-6 pr-12"> | |
| 114 | <DialogDescription className="sr-only">For the desktop app on this computer. Your account's settings, notifications included, are on g1t itself.</DialogDescription> | |
| 115 | <h3 className="mb-4 text-base font-semibold tracking-tight">{SECTIONS.find((item) => item.id === section)?.label}</h3> | |
| 116 | {view ? ( | |
| 117 | <div className="space-y-2"> | |
| 118 | {section === "general" && <General view={view} change={change} setView={setView} fail={fail} />} | |
| 119 | {section === "runner" && <RunnerSection view={view} runner={live} change={change} fail={fail} />} | |
| 120 | {section === "notifications" && <Notifications view={view} change={change} />} | |
| 121 | {section === "window" && <WindowSection view={view} change={change} fail={fail} />} | |
| 122 | {section === "updates" && <Updates view={view} change={change} checking={checking} fail={fail} />} | |
| 123 | {section === "server" && <Server view={view} fail={fail} />} | |
| 124 | {section === "about" && <About view={view} />} | |
| 125 | </div> | |
| 126 | ) : ( | |
| 127 | <p className="text-sm text-muted">{error ? null : "Loading…"}</p> | |
| 128 | )} | |
| 129 | {error && ( | |
| 130 | <p role="alert" className="mt-4 text-sm text-danger"> | |
| 131 | {error} | |
| 132 | </p> | |
| 133 | )} | |
| 134 | </div> | |
| 135 | </DialogContent> | |
| 136 | </Dialog> | |
| 137 | ); | |
| 138 | } | |
| 139 | ||
| 140 | type Change = (patch: Partial<DesktopSettings>) => void; | |
| 141 | type Fail = (reason: unknown) => void; | |
| 142 | ||
| 143 | /** A setting that isn't a switch: its title, what it means, and its control. */ | |
| 144 | function SettingCard({ title, children, control, id }: { title: ReactNode; children?: ReactNode; control?: ReactNode; id?: string }) { | |
| 145 | return ( | |
| 146 | <Card className="flex flex-wrap items-center justify-between gap-x-4 gap-y-3 p-4"> | |
| 147 | <div className="min-w-0 basis-60 grow"> | |
| 148 | <p id={id} className="text-sm font-medium text-fg"> | |
| 149 | {title} | |
| 150 | </p> | |
| 151 | {children && <div className="mt-1 text-sm text-muted">{children}</div>} | |
| 152 | </div> | |
| 153 | {control && <div className="flex shrink-0 flex-wrap items-center gap-2">{control}</div>} | |
| 154 | </Card> | |
| 155 | ); | |
| 156 | } | |
| 157 | ||
| 158 | function General({ view, change, setView, fail }: { view: DesktopSettingsView; change: Change; setView: (view: DesktopSettingsView) => void; fail: Fail }) { | |
| 159 | return ( | |
| 160 | <> | |
| 161 | <SwitchCard | |
| 162 | title="Open g1t when you sign in" | |
| 163 | checked={view.openAtLogin} | |
| 164 | onCheckedChange={(on) => { | |
| 165 | const before = view; | |
| 166 | setView({ ...view, openAtLogin: on }); | |
| 167 | desktopSettings() | |
| 168 | ?.setOpenAtLogin(on) | |
| 169 | .then(setView, (reason: unknown) => { | |
| 170 | setView(before); | |
| 171 | fail(reason); | |
| 172 | }); | |
| 173 | }} | |
| 174 | > | |
| 175 | g1t starts with your computer. | |
| 176 | </SwitchCard> | |
| 177 | {/* Starting in the tray only means something when g1t starts at sign-in. */} | |
| 178 | <SwitchCard title="Start in the tray" checked={view.startHidden} disabled={!view.openAtLogin} onCheckedChange={(on) => change({ startHidden: on })}> | |
| 179 | Opened at sign-in, g1t waits in the tray without opening its window. | |
| 180 | </SwitchCard> | |
| 181 | <SwitchCard title="Keep g1t running when the window closes" checked={view.closeToTray} onCheckedChange={(on) => change({ closeToTray: on })}> | |
| 182 | It stays in the tray for notifications. Turned off, closing the window quits. | |
| 183 | </SwitchCard> | |
| 184 | </> | |
| 185 | ); | |
| 186 | } | |
| 187 | ||
| 188 | const BACKENDS: { value: DesktopBackend; label: string }[] = [ | |
| 189 | { value: "sandboxed", label: "Sandboxed (recommended)" }, | |
| 190 | { value: "docker", label: "In Docker" }, | |
| 191 | { value: "host", label: "Directly on this computer" }, | |
| 192 | ]; | |
| 193 | ||
| 194 | function RunnerSection({ view, runner, change, fail }: { view: DesktopSettingsView; runner: DesktopRunnerView | null; change: Change; fail: Fail }) { | |
| 195 | const backendId = useId(); | |
| 196 | const workspaceId = useId(); | |
| 197 | // Taking jobs out of the sandbox is chosen in the Runner window, a page | |
| 198 | // of the app's own: no page from the network may do it (src/bridge.rs). | |
| 199 | const [outside, setOutside] = useState(false); | |
| 200 | if (!runner) return <p className="text-sm text-muted">Loading…</p>; | |
| 201 | const backend = runner.backend; | |
| 202 | const note = BACKEND_NOTE[backend] ?? BACKEND_NOTE.sandboxed; | |
| 203 | const workspaces = [...runner.workspaces]; | |
| 204 | if (runner.workspace && !workspaces.some((w) => w.slug === runner.workspace)) workspaces.unshift({ slug: runner.workspace, name: null }); | |
| 205 | const diagnosis = runner.diagnosis; | |
| 206 | const kinds = diagnosis.capabilities ?? []; | |
| 207 | const cardsSayIt = !!diagnosis.in_capabilities && kinds.length > 0; | |
| 208 | const status = runnerStatusKey(runner); | |
| 209 | return ( | |
| 210 | <> | |
| 211 | {!cardsSayIt && ( | |
| 212 | <Card className="flex items-start gap-3 p-4" role="status"> | |
| 213 | <span className={cn("mt-1.5 size-2 shrink-0 rounded-full", DOT[diagnosis.tone])} aria-hidden /> | |
| 214 | <div className="min-w-0"> | |
| 215 | <p className="text-sm font-medium text-fg">{diagnosis.title}</p> | |
| 216 | {diagnosis.cause && <p className="mt-0.5 text-sm text-muted">{diagnosis.cause}</p>} | |
| 217 | {diagnosis.fix && <p className="mt-0.5 text-sm text-fg-soft">{diagnosis.fix}</p>} | |
| 218 | {diagnosis.actions.length > 0 && ( | |
| 219 | <div className="mt-2 flex flex-wrap gap-2"> | |
| 220 | <Actions actions={diagnosis.actions} status={status} onError={fail} /> | |
| 221 | </div> | |
| 222 | )} | |
| 223 | </div> | |
| 224 | </Card> | |
| 225 | )} | |
| 226 | {kinds.length > 0 && ( | |
| 227 | <div className={cn("grid gap-2", kinds.length > 1 && "@xl:grid-cols-2")}> | |
| 228 | {kinds.map((kind) => ( | |
| 229 | <KindCard key={kind.id} capability={kind} here runners={[]} update={diagnosis.update ?? null} view={runner} onError={fail} /> | |
| 230 | ))} | |
| 231 | </div> | |
| 232 | )} | |
| 233 | <SwitchCard title="Run jobs on this computer while g1t is open" checked={view.runner} onCheckedChange={(on) => change({ runner: on })}> | |
| 234 | Your workspace's <code>runs-on: self-hosted</code> jobs, at no cost. Off, nothing runs here until you turn it on again. | |
| 235 | </SwitchCard> | |
| 236 | <SettingCard | |
| 237 | id={backendId} | |
| 238 | title="How jobs run" | |
| 239 | control={ | |
| 240 | <SelectField | |
| 241 | aria-labelledby={backendId} | |
| 242 | size="sm" | |
| 243 | className="w-56" | |
| 244 | align="end" | |
| 245 | value={backend} | |
| 246 | options={BACKENDS} | |
| 247 | onValueChange={(chosen) => { | |
| 248 | if (chosen === "sandboxed") { | |
| 249 | setOutside(false); | |
| 250 | change({ runnerBackend: "sandboxed" }); | |
| 251 | return; | |
| 252 | } | |
| 253 | setOutside(true); | |
| 254 | void desktopRunner()?.act("runner_window").catch(fail); | |
| 255 | }} | |
| 256 | /> | |
| 257 | } | |
| 258 | > | |
| 259 | <span className={note.warn ? "text-warn" : undefined}>{note.text}</span> | |
| 260 | {outside && <span className="mt-1 block text-fg-soft">Jobs outside the sandbox can reach your files, so that is chosen in the Runner window, which has opened.</span>} | |
| 261 | </SettingCard> | |
| 262 | <SettingCard | |
| 263 | id={workspaceId} | |
| 264 | title="Workspace" | |
| 265 | control={ | |
| 266 | <SelectField | |
| 267 | aria-labelledby={workspaceId} | |
| 268 | size="sm" | |
| 269 | className="w-56" | |
| 270 | align="end" | |
| 271 | value={runner.workspace ?? ""} | |
| 272 | disabled={workspaces.length === 0} | |
| 273 | placeholder="Open g1t, signed in, to choose" | |
| 274 | options={workspaces.map((w) => ({ value: w.slug, label: w.name ? `${w.name} (${w.slug})` : w.slug }))} | |
| 275 | onValueChange={(slug) => change({ runnerWorkspace: slug || null })} | |
| 276 | /> | |
| 277 | } | |
| 278 | > | |
| 279 | The workspace whose jobs it runs. Only a workspace's owners can add runners to it. | |
| 280 | </SettingCard> | |
| 281 | <SettingCard | |
| 282 | title={`This computer: ${runner.machine}`} | |
| 283 | control={runner.link ? <ActButton action="view_runners" status={status} fail={fail} label="View on g1t" /> : null} | |
| 284 | > | |
| 285 | {[runner.version ? `g1t-runner ${runner.version}` : null, diagnosis.update?.checked].filter(Boolean).join(" · ") || null} | |
| 286 | </SettingCard> | |
| 287 | </> | |
| 288 | ); | |
| 289 | } | |
| 290 | ||
| 291 | function Notifications({ view, change }: { view: DesktopSettingsView; change: Change }) { | |
| 292 | const words = platformWords(view.platform); | |
| 293 | return ( | |
| 294 | <> | |
| 295 | <SwitchCard title="Show notifications" checked={view.notifications} onCheckedChange={(on) => change({ notifications: on })}> | |
| 296 | From your system, for what needs you while g1t is behind another window. Which things notify you is set on g1t. | |
| 297 | </SwitchCard> | |
| 298 | <SwitchCard title={words.flash} checked={view.flash} onCheckedChange={(on) => change({ flash: on })}> | |
| 299 | When a notification arrives and the window is not in front. | |
| 300 | </SwitchCard> | |
| 301 | <SwitchCard title="Show a badge on the icon" checked={view.badge} onCheckedChange={(on) => change({ badge: on })}> | |
| 302 | How many things are waiting for you. | |
| 303 | </SwitchCard> | |
| 304 | </> | |
| 305 | ); | |
| 306 | } | |
| 307 | ||
| 308 | function WindowSection({ view, change, fail }: { view: DesktopSettingsView; change: Change; fail: Fail }) { | |
| 309 | const words = platformWords(view.platform); | |
| 310 | const zoomId = useId(); | |
| 311 | const restart = useBusy(String(view.titleBarChanged), fail); | |
| 312 | return ( | |
| 313 | <> | |
| 314 | <SwitchCard title="Use the system's title bar" checked={view.systemTitleBar} onCheckedChange={(on) => change({ systemTitleBar: on })}> | |
| 315 | {words.systemTitleBar} | |
| 316 | </SwitchCard> | |
| 317 | {view.titleBarChanged && ( | |
| 318 | <SettingCard | |
| 319 | title="Restart to change the title bar" | |
| 320 | control={ | |
| 321 | <Button size="sm" variant="accent" busy={restart.busy} onClick={() => restart.run(() => desktopSettings()?.restart())}> | |
| 322 | Restart now | |
| 323 | </Button> | |
| 324 | } | |
| 325 | > | |
| 326 | g1t opens again where you were. | |
| 327 | </SettingCard> | |
| 328 | )} | |
| 329 | <SettingCard | |
| 330 | id={zoomId} | |
| 331 | title="Zoom" | |
| 332 | control={ | |
| 333 | <SelectField | |
| 334 | aria-labelledby={zoomId} | |
| 335 | size="sm" | |
| 336 | className="w-28" | |
| 337 | align="end" | |
| 338 | value={String(ZOOMS.includes(view.zoom) ? view.zoom : 1)} | |
| 339 | options={ZOOMS.map((zoom) => ({ value: String(zoom), label: `${Math.round(zoom * 100)}%` }))} | |
| 340 | onValueChange={(value) => change({ zoom: Number(value) || 1 })} | |
| 341 | /> | |
| 342 | } | |
| 343 | > | |
| 344 | {words.zoom} | |
| 345 | </SettingCard> | |
| 346 | </> | |
| 347 | ); | |
| 348 | } | |
| 349 | ||
| 350 | /** Where a check for updates stands: under way, and what it last said that the view doesn't. */ | |
| 351 | type Checking = { checking: boolean; said: string | null; look: () => void }; | |
| 352 | ||
| 353 | /** | |
| 354 | * Looking for updates, kept by the dialog rather than its Updates section, | |
| 355 | * so a check carries on while another section is shown. `check` changes | |
| 356 | * each time the app asks for one ("Check for updates…" in a menu). | |
| 357 | */ | |
| 358 | function useChecking(view: DesktopSettingsView | null, setView: (update: (view: DesktopSettingsView | null) => DesktopSettingsView | null) => void, check: number): Checking { | |
| 359 | const [checking, setChecking] = useState(false); | |
| 360 | const [said, setSaid] = useState<string | null>(null); | |
| 361 | const handled = useRef(0); | |
| 362 | const look = useCallback(() => { | |
| 363 | const bridge = desktopSettings(); | |
| 364 | if (!bridge) return; | |
| 365 | setChecking(true); | |
| 366 | setSaid("Checking, and downloading any new version…"); | |
| 367 | bridge | |
| 368 | .checkForUpdates() | |
| 369 | .then( | |
| 370 | (answer) => { | |
| 371 | const ready = answer.startsWith("ready:") ? answer.slice(6) : null; | |
| 372 | setView((current) => (current ? { ...current, updateReady: ready ?? current.updateReady, checkedAt: Date.now() } : current)); | |
| 373 | setSaid(null); | |
| 374 | }, | |
| 375 | (reason: unknown) => setSaid(checkFailed(reason)), | |
| 376 | ) | |
| 377 | .finally(() => setChecking(false)); | |
| 378 | }, [setView]); | |
| 379 | // Asked by the app: once the settings are there to say what it finds. | |
| 380 | useEffect(() => { | |
| 381 | if (!view || check <= handled.current) return; | |
| 382 | handled.current = check; | |
| 383 | look(); | |
| 384 | }, [view, check, look]); | |
| 385 | return { checking, said, look }; | |
| 386 | } | |
| 387 | ||
| 388 | function Updates({ view, change, checking, fail }: { view: DesktopSettingsView; change: Change; checking: Checking; fail: Fail }) { | |
| 389 | const restart = useBusy(view.updateReady ?? "", fail); | |
| 390 | const { said, look } = checking; | |
| 391 | return ( | |
| 392 | <> | |
| 393 | <SwitchCard title="Download updates by themselves" checked={view.autoUpdate} onCheckedChange={(on) => change({ autoUpdate: on })}> | |
| 394 | Each one installs when you restart or quit g1t, never in the middle of something. | |
| 395 | </SwitchCard> | |
| 396 | <SettingCard | |
| 397 | title={`g1t ${view.version}`} | |
| 398 | control={ | |
| 399 | <> | |
| 400 | {view.updateReady && ( | |
| 401 | <Button size="sm" variant="accent" busy={restart.busy} onClick={() => restart.run(() => desktopSettings()?.restartToUpdate())}> | |
| 402 | Restart now | |
| 403 | </Button> | |
| 404 | )} | |
| 405 | <Button size="sm" variant="outline" busy={checking.checking} onClick={look}> | |
| 406 | Check for updates | |
| 407 | </Button> | |
| 408 | </> | |
| 409 | } | |
| 410 | > | |
| 411 | <span aria-live="polite">{restart.busy ? "Installing…" : (said ?? updateLine(view))}</span> | |
| 412 | </SettingCard> | |
| 413 | </> | |
| 414 | ); | |
| 415 | } | |
| 416 | ||
| 417 | function Server({ view, fail }: { view: DesktopSettingsView; fail: Fail }) { | |
| 418 | const { busy, run } = useBusy(view.server, fail); | |
| 419 | return ( | |
| 420 | <SettingCard | |
| 421 | title={hostOf(view.server)} | |
| 422 | control={ | |
| 423 | <Button size="sm" variant="outline" busy={busy} onClick={() => run(() => desktopSettings()?.changeServer(), false)}> | |
| 424 | Change… | |
| 425 | </Button> | |
| 426 | } | |
| 427 | > | |
| 428 | The g1t this app opens. Change it if you run g1t yourself. | |
| 429 | </SettingCard> | |
| 430 | ); | |
| 431 | } | |
| 432 | ||
| 433 | function About({ view }: { view: DesktopSettingsView }) { | |
| 434 | const platform = view.platform === "macos" ? "macOS" : view.platform === "windows" ? "Windows" : view.platform === "linux" ? "Linux" : view.platform; | |
| 435 | return ( | |
| 436 | <SettingCard | |
| 437 | title={`g1t ${view.version}`} | |
| 438 | control={ | |
| 439 | <Button size="sm" variant="outline" asChild> | |
| 440 | <a href="https://docs.g1t.sh/guides/desktop/"> | |
| 441 | About the desktop app | |
| 442 | <ExternalLink size={13} aria-hidden /> | |
| 443 | </a> | |
| 444 | </Button> | |
| 445 | } | |
| 446 | > | |
| 447 | The desktop app for {platform}, opening {hostOf(view.server)}. Your account's settings, notifications included, are on g1t itself. | |
| 448 | </SettingCard> | |
| 449 | ); | |
| 450 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.