Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

479 linesCodeBlameRaw
1import { ExternalLink } from "lucide-react";
2import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react";
3
4import type { DesktopBackend } from "@g1t/contracts";
5
6import { useBusy } from "../lib/busy";
7import { type DesktopRunnerView, desktopRunner, runnerStatusKey } from "../lib/desktop-runner";
8import {
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";
22import { cn } from "../lib/cn";
23import { ActButton, Actions, DOT, KindCard } from "./desktop-runners";
24import { Button } from "./ui/button";
25import { Card } from "./ui/card";
26import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./ui/dialog";
27import { SelectField } from "./ui/select";
28import { 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 */
39export 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&apos;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
142type Change = (patch: Partial<DesktopSettings>) => void;
143type Fail = (reason: unknown) => void;
144
145/** A setting that isn't a switch: its title, what it means, and its control. */
146function 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
160function 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
190const 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
196function 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&apos;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&apos;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
297function 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
314function 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. */
376type 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 */
383function 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
417function 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
446function 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
462function 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&apos;s settings, notifications included, are on g1t itself.
477 </SettingCard>
478 );
479}