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.

g1t.sh
450 linesCodeBlameRaw

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 the desktop app's settings and runner in the main window: the runner is a dot beside the title bar's Settings button with a menu of its own (its state, each kind of job and what it runs, Start and Stop, updates, the log), Settings opens as a dialog in the window with its sections down the side, the separate windows stay only for when the site isn't showing, and every button that asks the app for something shows it is working until something happens.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}

This file's history is long; its oldest lines are credited to the oldest commit read.