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 branch 'main' into rm-agents | 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); | |
| Merge branch 'worktree-agent-a6b141eb3a7e9cf01' | 312 | const restartForTools = useBusy(String(view.developerToolsChanged), fail); |
| Merge branch 'main' into rm-agents | 313 | return ( |
| 314 | <> | |
| 315 | <SwitchCard title="Use the system's title bar" checked={view.systemTitleBar} onCheckedChange={(on) => change({ systemTitleBar: on })}> | |
| 316 | {words.systemTitleBar} | |
| 317 | </SwitchCard> | |
| 318 | {view.titleBarChanged && ( | |
| 319 | <SettingCard | |
| 320 | title="Restart to change the title bar" | |
| 321 | control={ | |
| 322 | <Button size="sm" variant="accent" busy={restart.busy} onClick={() => restart.run(() => desktopSettings()?.restart())}> | |
| 323 | Restart now | |
| 324 | </Button> | |
| 325 | } | |
| 326 | > | |
| 327 | g1t opens again where you were. | |
| 328 | </SettingCard> | |
| 329 | )} | |
| 330 | <SettingCard | |
| 331 | id={zoomId} | |
| 332 | title="Zoom" | |
| 333 | control={ | |
| 334 | <SelectField | |
| 335 | aria-labelledby={zoomId} | |
| 336 | size="sm" | |
| 337 | className="w-28" | |
| 338 | align="end" | |
| 339 | value={String(ZOOMS.includes(view.zoom) ? view.zoom : 1)} | |
| 340 | options={ZOOMS.map((zoom) => ({ value: String(zoom), label: `${Math.round(zoom * 100)}%` }))} | |
| 341 | onValueChange={(value) => change({ zoom: Number(value) || 1 })} | |
| 342 | /> | |
| 343 | } | |
| 344 | > | |
| 345 | {words.zoom} | |
| 346 | </SettingCard> | |
| Merge branch 'worktree-agent-a6b141eb3a7e9cf01' | 347 | {/* An app from before Developer tools has no such setting to change. */} |
| 348 | {typeof view.developerTools === "boolean" && ( | |
| 349 | <SwitchCard title="Developer tools" checked={view.developerTools} onCheckedChange={(on) => change({ developerTools: on })}> | |
| 350 | {words.developerTools} | |
| 351 | </SwitchCard> | |
| 352 | )} | |
| 353 | {view.developerToolsChanged && !view.titleBarChanged && ( | |
| 354 | <SettingCard | |
| 355 | title={view.developerTools ? "Restart to turn on developer tools" : "Restart to turn off developer tools"} | |
| 356 | control={ | |
| 357 | <Button size="sm" variant="accent" busy={restartForTools.busy} onClick={() => restartForTools.run(() => desktopSettings()?.restart())}> | |
| 358 | Restart now | |
| 359 | </Button> | |
| 360 | } | |
| 361 | > | |
| 362 | g1t opens again where you were. | |
| 363 | </SettingCard> | |
| 364 | )} | |
| Merge branch 'main' into rm-agents | 365 | </> |
| 366 | ); | |
| 367 | } | |
| 368 | ||
| 369 | /** Where a check for updates stands: under way, and what it last said that the view doesn't. */ | |
| 370 | type Checking = { checking: boolean; said: string | null; look: () => void }; | |
| 371 | ||
| 372 | /** | |
| 373 | * Looking for updates, kept by the dialog rather than its Updates section, | |
| 374 | * so a check carries on while another section is shown. `check` changes | |
| 375 | * each time the app asks for one ("Check for updates…" in a menu). | |
| 376 | */ | |
| 377 | function useChecking(view: DesktopSettingsView | null, setView: (update: (view: DesktopSettingsView | null) => DesktopSettingsView | null) => void, check: number): Checking { | |
| 378 | const [checking, setChecking] = useState(false); | |
| 379 | const [said, setSaid] = useState<string | null>(null); | |
| 380 | const handled = useRef(0); | |
| 381 | const look = useCallback(() => { | |
| 382 | const bridge = desktopSettings(); | |
| 383 | if (!bridge) return; | |
| 384 | setChecking(true); | |
| 385 | setSaid("Checking, and downloading any new version…"); | |
| 386 | bridge | |
| 387 | .checkForUpdates() | |
| 388 | .then( | |
| 389 | (answer) => { | |
| 390 | const ready = answer.startsWith("ready:") ? answer.slice(6) : null; | |
| 391 | setView((current) => (current ? { ...current, updateReady: ready ?? current.updateReady, checkedAt: Date.now() } : current)); | |
| 392 | setSaid(null); | |
| 393 | }, | |
| 394 | (reason: unknown) => setSaid(checkFailed(reason)), | |
| 395 | ) | |
| 396 | .finally(() => setChecking(false)); | |
| 397 | }, [setView]); | |
| 398 | // Asked by the app: once the settings are there to say what it finds. | |
| 399 | useEffect(() => { | |
| 400 | if (!view || check <= handled.current) return; | |
| 401 | handled.current = check; | |
| 402 | look(); | |
| 403 | }, [view, check, look]); | |
| 404 | return { checking, said, look }; | |
| 405 | } | |
| 406 | ||
| 407 | function Updates({ view, change, checking, fail }: { view: DesktopSettingsView; change: Change; checking: Checking; fail: Fail }) { | |
| 408 | const restart = useBusy(view.updateReady ?? "", fail); | |
| 409 | const { said, look } = checking; | |
| 410 | return ( | |
| 411 | <> | |
| 412 | <SwitchCard title="Download updates by themselves" checked={view.autoUpdate} onCheckedChange={(on) => change({ autoUpdate: on })}> | |
| 413 | Each one installs when you restart or quit g1t, never in the middle of something. | |
| 414 | </SwitchCard> | |
| 415 | <SettingCard | |
| 416 | title={`g1t ${view.version}`} | |
| 417 | control={ | |
| 418 | <> | |
| 419 | {view.updateReady && ( | |
| 420 | <Button size="sm" variant="accent" busy={restart.busy} onClick={() => restart.run(() => desktopSettings()?.restartToUpdate())}> | |
| 421 | Restart now | |
| 422 | </Button> | |
| 423 | )} | |
| 424 | <Button size="sm" variant="outline" busy={checking.checking} onClick={look}> | |
| 425 | Check for updates | |
| 426 | </Button> | |
| 427 | </> | |
| 428 | } | |
| 429 | > | |
| 430 | <span aria-live="polite">{restart.busy ? "Installing…" : (said ?? updateLine(view))}</span> | |
| 431 | </SettingCard> | |
| 432 | </> | |
| 433 | ); | |
| 434 | } | |
| 435 | ||
| 436 | function Server({ view, fail }: { view: DesktopSettingsView; fail: Fail }) { | |
| 437 | const { busy, run } = useBusy(view.server, fail); | |
| 438 | return ( | |
| 439 | <SettingCard | |
| 440 | title={hostOf(view.server)} | |
| 441 | control={ | |
| 442 | <Button size="sm" variant="outline" busy={busy} onClick={() => run(() => desktopSettings()?.changeServer(), false)}> | |
| 443 | Change… | |
| 444 | </Button> | |
| 445 | } | |
| 446 | > | |
| 447 | The g1t this app opens. Change it if you run g1t yourself. | |
| 448 | </SettingCard> | |
| 449 | ); | |
| 450 | } | |
| 451 | ||
| 452 | function About({ view }: { view: DesktopSettingsView }) { | |
| 453 | const platform = view.platform === "macos" ? "macOS" : view.platform === "windows" ? "Windows" : view.platform === "linux" ? "Linux" : view.platform; | |
| 454 | return ( | |
| 455 | <SettingCard | |
| 456 | title={`g1t ${view.version}`} | |
| 457 | control={ | |
| 458 | <Button size="sm" variant="outline" asChild> | |
| 459 | <a href="https://docs.g1t.sh/guides/desktop/"> | |
| 460 | About the desktop app | |
| 461 | <ExternalLink size={13} aria-hidden /> | |
| 462 | </a> | |
| 463 | </Button> | |
| 464 | } | |
| 465 | > | |
| 466 | The desktop app for {platform}, opening {hostOf(view.server)}. Your account's settings, notifications included, are on g1t itself. | |
| 467 | </SettingCard> | |
| 468 | ); | |
| 469 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.