| 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 | } |