| 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( |
| 58 | (reason: unknown) => setError(reason instanceof Error ? reason.message : typeof reason === "string" ? reason : "That could not be changed."), |
| 59 | [], |
| 60 | ); |
| 61 | |
| 62 | // The settings as the app has them, each time it opens. |
| 63 | useEffect(() => { |
| 64 | if (!open) return; |
| 65 | setError(null); |
| 66 | desktopSettings()?.get().then(setView).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"> |
| 115 | For the desktop app on this computer. Your account's settings, notifications included, are on g1t itself. |
| 116 | </DialogDescription> |
| 117 | <h3 className="mb-4 text-base font-semibold tracking-tight">{SECTIONS.find((item) => item.id === section)?.label}</h3> |
| 118 | {view ? ( |
| 119 | <div className="space-y-2"> |
| 120 | {section === "general" && <General view={view} change={change} setView={setView} fail={fail} />} |
| 121 | {section === "runner" && <RunnerSection view={view} runner={live} change={change} fail={fail} />} |
| 122 | {section === "notifications" && <Notifications view={view} change={change} />} |
| 123 | {section === "window" && <WindowSection view={view} change={change} fail={fail} />} |
| 124 | {section === "updates" && <Updates view={view} change={change} checking={checking} fail={fail} />} |
| 125 | {section === "server" && <Server view={view} fail={fail} />} |
| 126 | {section === "about" && <About view={view} />} |
| 127 | </div> |
| 128 | ) : ( |
| 129 | <p className="text-sm text-muted">{error ? null : "Loading…"}</p> |
| 130 | )} |
| 131 | {error && ( |
| 132 | <p role="alert" className="mt-4 text-sm text-danger"> |
| 133 | {error} |
| 134 | </p> |
| 135 | )} |
| 136 | </div> |
| 137 | </DialogContent> |
| 138 | </Dialog> |
| 139 | ); |
| 140 | } |
| 141 | |
| 142 | type Change = (patch: Partial<DesktopSettings>) => void; |
| 143 | type Fail = (reason: unknown) => void; |
| 144 | |
| 145 | /** A setting that isn't a switch: its title, what it means, and its control. */ |
| 146 | function SettingCard({ title, children, control, id }: { title: ReactNode; children?: ReactNode; control?: ReactNode; id?: string }) { |
| 147 | return ( |
| 148 | <Card className="flex flex-wrap items-center justify-between gap-x-4 gap-y-3 p-4"> |
| 149 | <div className="min-w-0 basis-60 grow"> |
| 150 | <p id={id} className="text-sm font-medium text-fg"> |
| 151 | {title} |
| 152 | </p> |
| 153 | {children && <div className="mt-1 text-sm text-muted">{children}</div>} |
| 154 | </div> |
| 155 | {control && <div className="flex shrink-0 flex-wrap items-center gap-2">{control}</div>} |
| 156 | </Card> |
| 157 | ); |
| 158 | } |
| 159 | |
| 160 | function General({ view, change, setView, fail }: { view: DesktopSettingsView; change: Change; setView: (view: DesktopSettingsView) => void; fail: Fail }) { |
| 161 | return ( |
| 162 | <> |
| 163 | <SwitchCard |
| 164 | title="Open g1t when you sign in" |
| 165 | checked={view.openAtLogin} |
| 166 | onCheckedChange={(on) => { |
| 167 | const before = view; |
| 168 | setView({ ...view, openAtLogin: on }); |
| 169 | desktopSettings() |
| 170 | ?.setOpenAtLogin(on) |
| 171 | .then(setView, (reason: unknown) => { |
| 172 | setView(before); |
| 173 | fail(reason); |
| 174 | }); |
| 175 | }} |
| 176 | > |
| 177 | g1t starts with your computer. |
| 178 | </SwitchCard> |
| 179 | {/* Starting in the tray only means something when g1t starts at sign-in. */} |
| 180 | <SwitchCard title="Start in the tray" checked={view.startHidden} disabled={!view.openAtLogin} onCheckedChange={(on) => change({ startHidden: on })}> |
| 181 | Opened at sign-in, g1t waits in the tray without opening its window. |
| 182 | </SwitchCard> |
| 183 | <SwitchCard title="Keep g1t running when the window closes" checked={view.closeToTray} onCheckedChange={(on) => change({ closeToTray: on })}> |
| 184 | It stays in the tray for notifications. Turned off, closing the window quits. |
| 185 | </SwitchCard> |
| 186 | </> |
| 187 | ); |
| 188 | } |
| 189 | |
| 190 | const BACKENDS: { value: DesktopBackend; label: string }[] = [ |
| 191 | { value: "sandboxed", label: "Sandboxed (recommended)" }, |
| 192 | { value: "docker", label: "In Docker" }, |
| 193 | { value: "host", label: "Directly on this computer" }, |
| 194 | ]; |
| 195 | |
| 196 | function RunnerSection({ view, runner, change, fail }: { view: DesktopSettingsView; runner: DesktopRunnerView | null; change: Change; fail: Fail }) { |
| 197 | const backendId = useId(); |
| 198 | const workspaceId = useId(); |
| 199 | // Taking jobs out of the sandbox is chosen in the Runner window, a page |
| 200 | // of the app's own: no page from the network may do it (src/bridge.rs). |
| 201 | const [outside, setOutside] = useState(false); |
| 202 | if (!runner) return <p className="text-sm text-muted">Loading…</p>; |
| 203 | const backend = runner.backend; |
| 204 | const note = BACKEND_NOTE[backend] ?? BACKEND_NOTE.sandboxed; |
| 205 | const workspaces = [...runner.workspaces]; |
| 206 | if (runner.workspace && !workspaces.some((w) => w.slug === runner.workspace)) workspaces.unshift({ slug: runner.workspace, name: null }); |
| 207 | const diagnosis = runner.diagnosis; |
| 208 | const kinds = diagnosis.capabilities ?? []; |
| 209 | const cardsSayIt = !!diagnosis.in_capabilities && kinds.length > 0; |
| 210 | const status = runnerStatusKey(runner); |
| 211 | return ( |
| 212 | <> |
| 213 | {!cardsSayIt && ( |
| 214 | <Card className="flex items-start gap-3 p-4" role="status"> |
| 215 | <span className={cn("mt-1.5 size-2 shrink-0 rounded-full", DOT[diagnosis.tone])} aria-hidden /> |
| 216 | <div className="min-w-0"> |
| 217 | <p className="text-sm font-medium text-fg">{diagnosis.title}</p> |
| 218 | {diagnosis.cause && <p className="mt-0.5 text-sm text-muted">{diagnosis.cause}</p>} |
| 219 | {diagnosis.fix && <p className="mt-0.5 text-sm text-fg-soft">{diagnosis.fix}</p>} |
| 220 | {diagnosis.actions.length > 0 && ( |
| 221 | <div className="mt-2 flex flex-wrap gap-2"> |
| 222 | <Actions actions={diagnosis.actions} status={status} onError={fail} /> |
| 223 | </div> |
| 224 | )} |
| 225 | </div> |
| 226 | </Card> |
| 227 | )} |
| 228 | {kinds.length > 0 && ( |
| 229 | <div className={cn("grid gap-2", kinds.length > 1 && "@xl:grid-cols-2")}> |
| 230 | {kinds.map((kind) => ( |
| 231 | <KindCard key={kind.id} capability={kind} here runners={[]} update={diagnosis.update ?? null} view={runner} onError={fail} /> |
| 232 | ))} |
| 233 | </div> |
| 234 | )} |
| 235 | <SwitchCard title="Run jobs on this computer while g1t is open" checked={view.runner} onCheckedChange={(on) => change({ runner: on })}> |
| 236 | Your workspace's <code>runs-on: self-hosted</code> jobs, at no cost. Off, nothing runs here until you turn it on again. |
| 237 | </SwitchCard> |
| 238 | <SettingCard |
| 239 | id={backendId} |
| 240 | title="How jobs run" |
| 241 | control={ |
| 242 | <SelectField |
| 243 | aria-labelledby={backendId} |
| 244 | size="sm" |
| 245 | className="w-56" |
| 246 | align="end" |
| 247 | value={backend} |
| 248 | options={BACKENDS} |
| 249 | onValueChange={(chosen) => { |
| 250 | if (chosen === "sandboxed") { |
| 251 | setOutside(false); |
| 252 | change({ runnerBackend: "sandboxed" }); |
| 253 | return; |
| 254 | } |
| 255 | setOutside(true); |
| 256 | void desktopRunner()?.act("runner_window").catch(fail); |
| 257 | }} |
| 258 | /> |
| 259 | } |
| 260 | > |
| 261 | <span className={note.warn ? "text-warn" : undefined}>{note.text}</span> |
| 262 | {outside && ( |
| 263 | <span className="mt-1 block text-fg-soft"> |
| 264 | Jobs outside the sandbox can reach your files, so that is chosen in the Runner window, which has opened. |
| 265 | </span> |
| 266 | )} |
| 267 | </SettingCard> |
| 268 | <SettingCard |
| 269 | id={workspaceId} |
| 270 | title="Workspace" |
| 271 | control={ |
| 272 | <SelectField |
| 273 | aria-labelledby={workspaceId} |
| 274 | size="sm" |
| 275 | className="w-56" |
| 276 | align="end" |
| 277 | value={runner.workspace ?? ""} |
| 278 | disabled={workspaces.length === 0} |
| 279 | placeholder="Open g1t, signed in, to choose" |
| 280 | options={workspaces.map((w) => ({ value: w.slug, label: w.name ? `${w.name} (${w.slug})` : w.slug }))} |
| 281 | onValueChange={(slug) => change({ runnerWorkspace: slug || null })} |
| 282 | /> |
| 283 | } |
| 284 | > |
| 285 | The workspace whose jobs it runs. Only a workspace's owners can add runners to it. |
| 286 | </SettingCard> |
| 287 | <SettingCard |
| 288 | title={`This computer: ${runner.machine}`} |
| 289 | control={runner.link ? <ActButton action="view_runners" status={status} fail={fail} label="View on g1t" /> : null} |
| 290 | > |
| 291 | {[runner.version ? `g1t-runner ${runner.version}` : null, diagnosis.update?.checked].filter(Boolean).join(" · ") || null} |
| 292 | </SettingCard> |
| 293 | </> |
| 294 | ); |
| 295 | } |
| 296 | |
| 297 | function Notifications({ view, change }: { view: DesktopSettingsView; change: Change }) { |
| 298 | const words = platformWords(view.platform); |
| 299 | return ( |
| 300 | <> |
| 301 | <SwitchCard title="Show notifications" checked={view.notifications} onCheckedChange={(on) => change({ notifications: on })}> |
| 302 | From your system, for what needs you while g1t is behind another window. Which things notify you is set on g1t. |
| 303 | </SwitchCard> |
| 304 | <SwitchCard title={words.flash} checked={view.flash} onCheckedChange={(on) => change({ flash: on })}> |
| 305 | When a notification arrives and the window is not in front. |
| 306 | </SwitchCard> |
| 307 | <SwitchCard title="Show a badge on the icon" checked={view.badge} onCheckedChange={(on) => change({ badge: on })}> |
| 308 | How many things are waiting for you. |
| 309 | </SwitchCard> |
| 310 | </> |
| 311 | ); |
| 312 | } |
| 313 | |
| 314 | function WindowSection({ view, change, fail }: { view: DesktopSettingsView; change: Change; fail: Fail }) { |
| 315 | const words = platformWords(view.platform); |
| 316 | const zoomId = useId(); |
| 317 | const restart = useBusy(String(view.titleBarChanged), fail); |
| 318 | const restartForTools = useBusy(String(view.developerToolsChanged), fail); |
| 319 | return ( |
| 320 | <> |
| 321 | <SwitchCard title="Use the system's title bar" checked={view.systemTitleBar} onCheckedChange={(on) => change({ systemTitleBar: on })}> |
| 322 | {words.systemTitleBar} |
| 323 | </SwitchCard> |
| 324 | {view.titleBarChanged && ( |
| 325 | <SettingCard |
| 326 | title="Restart to change the title bar" |
| 327 | control={ |
| 328 | <Button size="sm" variant="accent" busy={restart.busy} onClick={() => restart.run(() => desktopSettings()?.restart())}> |
| 329 | Restart now |
| 330 | </Button> |
| 331 | } |
| 332 | > |
| 333 | g1t opens again where you were. |
| 334 | </SettingCard> |
| 335 | )} |
| 336 | <SettingCard |
| 337 | id={zoomId} |
| 338 | title="Zoom" |
| 339 | control={ |
| 340 | <SelectField |
| 341 | aria-labelledby={zoomId} |
| 342 | size="sm" |
| 343 | className="w-28" |
| 344 | align="end" |
| 345 | value={String(ZOOMS.includes(view.zoom) ? view.zoom : 1)} |
| 346 | options={ZOOMS.map((zoom) => ({ value: String(zoom), label: `${Math.round(zoom * 100)}%` }))} |
| 347 | onValueChange={(value) => change({ zoom: Number(value) || 1 })} |
| 348 | /> |
| 349 | } |
| 350 | > |
| 351 | {words.zoom} |
| 352 | </SettingCard> |
| 353 | {/* An app from before Developer tools has no such setting to change. */} |
| 354 | {typeof view.developerTools === "boolean" && ( |
| 355 | <SwitchCard title="Developer tools" checked={view.developerTools} onCheckedChange={(on) => change({ developerTools: on })}> |
| 356 | {words.developerTools} |
| 357 | </SwitchCard> |
| 358 | )} |
| 359 | {view.developerToolsChanged && !view.titleBarChanged && ( |
| 360 | <SettingCard |
| 361 | title={view.developerTools ? "Restart to turn on developer tools" : "Restart to turn off developer tools"} |
| 362 | control={ |
| 363 | <Button size="sm" variant="accent" busy={restartForTools.busy} onClick={() => restartForTools.run(() => desktopSettings()?.restart())}> |
| 364 | Restart now |
| 365 | </Button> |
| 366 | } |
| 367 | > |
| 368 | g1t opens again where you were. |
| 369 | </SettingCard> |
| 370 | )} |
| 371 | </> |
| 372 | ); |
| 373 | } |
| 374 | |
| 375 | /** Where a check for updates stands: under way, and what it last said that the view doesn't. */ |
| 376 | type Checking = { checking: boolean; said: string | null; look: () => void }; |
| 377 | |
| 378 | /** |
| 379 | * Looking for updates, kept by the dialog rather than its Updates section, |
| 380 | * so a check carries on while another section is shown. `check` changes |
| 381 | * each time the app asks for one ("Check for updates…" in a menu). |
| 382 | */ |
| 383 | function useChecking( |
| 384 | view: DesktopSettingsView | null, |
| 385 | setView: (update: (view: DesktopSettingsView | null) => DesktopSettingsView | null) => void, |
| 386 | check: number, |
| 387 | ): Checking { |
| 388 | const [checking, setChecking] = useState(false); |
| 389 | const [said, setSaid] = useState<string | null>(null); |
| 390 | const handled = useRef(0); |
| 391 | const look = useCallback(() => { |
| 392 | const bridge = desktopSettings(); |
| 393 | if (!bridge) return; |
| 394 | setChecking(true); |
| 395 | setSaid("Checking, and downloading any new version…"); |
| 396 | bridge |
| 397 | .checkForUpdates() |
| 398 | .then( |
| 399 | (answer) => { |
| 400 | const ready = answer.startsWith("ready:") ? answer.slice(6) : null; |
| 401 | setView((current) => (current ? { ...current, updateReady: ready ?? current.updateReady, checkedAt: Date.now() } : current)); |
| 402 | setSaid(null); |
| 403 | }, |
| 404 | (reason: unknown) => setSaid(checkFailed(reason)), |
| 405 | ) |
| 406 | .finally(() => setChecking(false)); |
| 407 | }, [setView]); |
| 408 | // Asked by the app: once the settings are there to say what it finds. |
| 409 | useEffect(() => { |
| 410 | if (!view || check <= handled.current) return; |
| 411 | handled.current = check; |
| 412 | look(); |
| 413 | }, [view, check, look]); |
| 414 | return { checking, said, look }; |
| 415 | } |
| 416 | |
| 417 | function Updates({ view, change, checking, fail }: { view: DesktopSettingsView; change: Change; checking: Checking; fail: Fail }) { |
| 418 | const restart = useBusy(view.updateReady ?? "", fail); |
| 419 | const { said, look } = checking; |
| 420 | return ( |
| 421 | <> |
| 422 | <SwitchCard title="Download updates by themselves" checked={view.autoUpdate} onCheckedChange={(on) => change({ autoUpdate: on })}> |
| 423 | Each one installs when you restart or quit g1t, never in the middle of something. |
| 424 | </SwitchCard> |
| 425 | <SettingCard |
| 426 | title={`g1t ${view.version}`} |
| 427 | control={ |
| 428 | <> |
| 429 | {view.updateReady && ( |
| 430 | <Button size="sm" variant="accent" busy={restart.busy} onClick={() => restart.run(() => desktopSettings()?.restartToUpdate())}> |
| 431 | Restart now |
| 432 | </Button> |
| 433 | )} |
| 434 | <Button size="sm" variant="outline" busy={checking.checking} onClick={look}> |
| 435 | Check for updates |
| 436 | </Button> |
| 437 | </> |
| 438 | } |
| 439 | > |
| 440 | <span aria-live="polite">{restart.busy ? "Installing…" : (said ?? updateLine(view))}</span> |
| 441 | </SettingCard> |
| 442 | </> |
| 443 | ); |
| 444 | } |
| 445 | |
| 446 | function Server({ view, fail }: { view: DesktopSettingsView; fail: Fail }) { |
| 447 | const { busy, run } = useBusy(view.server, fail); |
| 448 | return ( |
| 449 | <SettingCard |
| 450 | title={hostOf(view.server)} |
| 451 | control={ |
| 452 | <Button size="sm" variant="outline" busy={busy} onClick={() => run(() => desktopSettings()?.changeServer(), false)}> |
| 453 | Change… |
| 454 | </Button> |
| 455 | } |
| 456 | > |
| 457 | The g1t this app opens. Change it if you run g1t yourself. |
| 458 | </SettingCard> |
| 459 | ); |
| 460 | } |
| 461 | |
| 462 | function About({ view }: { view: DesktopSettingsView }) { |
| 463 | const platform = view.platform === "macos" ? "macOS" : view.platform === "windows" ? "Windows" : view.platform === "linux" ? "Linux" : view.platform; |
| 464 | return ( |
| 465 | <SettingCard |
| 466 | title={`g1t ${view.version}`} |
| 467 | control={ |
| 468 | <Button size="sm" variant="outline" asChild> |
| 469 | <a href="https://docs.g1t.sh/guides/desktop/"> |
| 470 | About the desktop app |
| 471 | <ExternalLink size={13} aria-hidden /> |
| 472 | </a> |
| 473 | </Button> |
| 474 | } |
| 475 | > |
| 476 | The desktop app for {platform}, opening {hostOf(view.server)}. Your account's settings, notifications included, are on g1t itself. |
| 477 | </SettingCard> |
| 478 | ); |
| 479 | } |