Skip to content
450 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((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&apos;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
140type Change = (patch: Partial<DesktopSettings>) => void;
141type Fail = (reason: unknown) => void;
142
143/** A setting that isn't a switch: its title, what it means, and its control. */
144function 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
158function 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
188const 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
194function 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&apos;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&apos;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
291function 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
308function 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. */
351type 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 */
358function 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
388function 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
417function 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
433function 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&apos;s settings, notifications included, are on g1t itself.
448 </SettingCard>
449 );
450}