import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { listen } from "@tauri-apps/api/event"; import * as api from "./api"; import Nav from "./components/Nav"; import Settings from "./components/Settings"; import { BTN_PRIMARY } from "./components/ui"; import { useAppearance, type Theme } from "./hooks/useAppearance"; import type { Config, Group, HiddenEvent, SwitchEvent } from "./types"; export default function App() { const [config, setConfig] = useState(null); const [activeId, setActiveId] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); const [focusHidden, setFocusHidden] = useState(null); const [theme, setTheme] = useAppearance("system"); const stageRef = useRef(null); const booted = useRef(false); // Read inside listeners, which are registered once and would otherwise // capture the first render's value forever. const activeRef = useRef(null); activeRef.current = activeId; const collapsed = config?.settings.navCollapsed ?? false; useEffect(() => { api.getConfig().then((c) => { setConfig(c); setTheme(c.settings.theme); const first = [...c.apps].sort((a, b) => a.order - b.order)[0]; setActiveId(first?.id ?? null); }); }, [setTheme]); /** * Tells Rust where the stage is. * * An app's webview is a native view that takes no part in CSS layout, so the * only way it lands in the right place is for the shell to measure the hole * it left and report it. */ const report = useCallback(() => { const el = stageRef.current; if (!el) return; const r = el.getBoundingClientRect(); void api.setStage(r.x, r.y, r.width, r.height); }, []); useLayoutEffect(() => { const el = stageRef.current; if (!el || !config) return; report(); const ro = new ResizeObserver(report); ro.observe(el); window.addEventListener("resize", report); return () => { ro.disconnect(); window.removeEventListener("resize", report); }; }, [config, report]); // Webviews are built only once the stage has been measured, so they are born // at the right size instead of against a guess. useEffect(() => { if (!config || booted.current || config.apps.length === 0) return; booted.current = true; report(); void api.bootstrap(); }, [config, report]); useEffect(() => { if (activeId) void api.setActive(activeId); }, [activeId]); useEffect(() => { const unlisten = [ // A link in one app that points at another: Rust decided, the shell moves. listen("switch-app", async (e) => { setActiveId(e.payload.appId); await api.navigateApp(e.payload.appId, e.payload.url); }), // Something was right-clicked away inside a page. listen("hidden-added", () => { void api.getConfig().then(setConfig); }), // "Manage hidden elements…" from a page's context menu. listen("manage-hidden", (e) => { setFocusHidden(e.payload); setSettingsOpen(true); }), ]; return () => { unlisten.forEach((p) => p.then((f) => f())); }; }, []); // A native view paints over anything the shell draws, so a dialog needs the // stage out of the way rather than merely on a higher z-index. useEffect(() => { if (!config) return; void (settingsOpen ? api.hideStage() : api.showStage()); }, [settingsOpen, config]); const toggleCollapse = () => { if (!config) return; const next = !collapsed; setConfig({ ...config, settings: { ...config.settings, navCollapsed: next } }); void api.setNavCollapsed(next); }; const toggleGroup = (g: Group) => { if (!config) return; const updated = { ...g, collapsed: !g.collapsed }; setConfig({ ...config, groups: config.groups.map((x) => (x.id === g.id ? updated : x)) }); void api.updateGroup(updated); }; const onTheme = (t: Theme) => { setTheme(t); void api.setTheme(t); }; if (!config) return null; return (
); }