Skip to content
79 linesCodeBlameRaw
1import { useEffect, useState } from "react";
2
3import { useDesktopRunner } from "../lib/desktop-runner";
4import { type SectionId, desktopSettings, sectionOf } from "../lib/desktop-settings";
5import { DesktopRunnerMenu } from "./desktop-runner-menu";
6import { DesktopSettingsDialog } from "./desktop-settings";
7
8/** Where the dot sits when the app doesn't say: near the right of the title bar, before the window's buttons. */
9const DOT_AT = 0.86;
10
11/**
12 * Inside the desktop app, on every page: the app's settings dialog and the
13 * runner's menu, which this page draws over itself since it fills the
14 * window under the title bar (lib/desktop-settings.ts). It tells the app it
15 * can as soon as it mounts, and no longer when it goes, so the app opens
16 * them here rather than in windows of their own. It draws nothing until
17 * the app asks. Outside the app, and in an app too old to ask, it is
18 * nothing at all.
19 */
20export function DesktopApp() {
21 // Found once hydrated: the server never has it, and the first render matches the server's.
22 const [bridge, setBridge] = useState<ReturnType<typeof desktopSettings>>(null);
23 useEffect(() => setBridge(desktopSettings()), []);
24 const runner = useDesktopRunner();
25 const [settings, setSettings] = useState<{ open: boolean; section: SectionId; check: number }>({ open: false, section: "general", check: 0 });
26 const [menu, setMenu] = useState<{ open: boolean; at: number }>({ open: false, at: DOT_AT });
27
28 useEffect(() => {
29 if (!bridge) return;
30 const stopOpen = bridge.onOpen((opening) => {
31 setMenu((now) => ({ ...now, open: false }));
32 setSettings((now) => ({
33 open: true,
34 // Opened again with no section named, it opens where it was.
35 section: opening?.section ? sectionOf(opening.section) : now.open ? now.section : "general",
36 check: opening?.check ? now.check + 1 : now.check,
37 }));
38 });
39 const stopMenu = bridge.onRunnerMenu((asked) => {
40 const at = typeof asked?.at === "number" && Number.isFinite(asked.at) ? asked.at : null;
41 setMenu((now) => ({ open: asked?.action === "toggle" ? !now.open : true, at: at ?? now.at }));
42 });
43 void bridge.ready(true).catch(() => {});
44 return () => {
45 stopOpen();
46 stopMenu();
47 void bridge.ready(false).catch(() => {});
48 };
49 }, [bridge]);
50
51 // The title bar's dot shows whether its menu is open.
52 useEffect(() => {
53 bridge?.setRunnerMenuOpen(menu.open);
54 }, [bridge, menu.open]);
55
56 if (!bridge) return null;
57 return (
58 <>
59 <DesktopRunnerMenu
60 open={menu.open}
61 at={menu.at}
62 runner={runner}
63 onOpenChange={(open) => setMenu((now) => ({ ...now, open }))}
64 onSettings={() => {
65 setMenu((now) => ({ ...now, open: false }));
66 setSettings((now) => ({ ...now, open: true, section: "runner" }));
67 }}
68 />
69 <DesktopSettingsDialog
70 open={settings.open}
71 section={settings.section}
72 check={settings.check}
73 runner={runner}
74 onOpenChange={(open) => setSettings((now) => ({ ...now, open }))}
75 onSection={(section) => setSettings((now) => ({ ...now, section }))}
76 />
77 </>
78 );
79}