The nav's right edge read as two lines because it was two things: its own border, and a sliver of the stage's background showing through a sub-pixel gap before the app's webview began. The stage rect is now rounded to whole pixels and to the same edges each time, so the app covers the stage exactly. Borders are one weight throughout and the lightest that still separates - slate-200 in light, slate-800 in dark. Nothing drawn heavier than it needs to be to read as an edge.
279 lines
9.3 KiB
TypeScript
279 lines
9.3 KiB
TypeScript
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, Dialog } from "./components/ui";
|
|
import { useAppearance, type Theme } from "./hooks/useAppearance";
|
|
import type {
|
|
Config,
|
|
Group,
|
|
HiddenEvent,
|
|
NotificationClick,
|
|
PasswordOffer,
|
|
SwitchEvent,
|
|
} from "./types";
|
|
|
|
export default function App() {
|
|
const [config, setConfig] = useState<Config | null>(null);
|
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
const [focusHidden, setFocusHidden] = useState<string | null>(null);
|
|
const [theme, setTheme] = useAppearance("system");
|
|
const [unread, setUnread] = useState<Record<string, number>>({});
|
|
const [backdrop, setBackdrop] = useState<string | null>(null);
|
|
const [offer, setOffer] = useState<PasswordOffer | null>(null);
|
|
const [saved, setSaved] = useState<string | null>(null);
|
|
|
|
const stageRef = useRef<HTMLDivElement>(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<string | null>(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();
|
|
/* Rounded to whole pixels, and to the same edges each time. A fractional
|
|
left edge leaves a sliver of the stage's own background showing between
|
|
the nav's border and the app — which reads as a second, lighter border
|
|
line running down the whole window. */
|
|
const x = Math.round(r.x);
|
|
const y = Math.round(r.y);
|
|
void api.setStage(x, y, Math.round(r.right) - x, Math.round(r.bottom) - y, 0);
|
|
}, []);
|
|
|
|
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<SwitchEvent>("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<HiddenEvent>("hidden-added", () => {
|
|
void api.getConfig().then(setConfig);
|
|
}),
|
|
// "Manage hidden elements…" from a page's context menu.
|
|
listen<string>("manage-hidden", (e) => {
|
|
setFocusHidden(e.payload);
|
|
setSettingsOpen(true);
|
|
}),
|
|
// A macOS banner was clicked. Switch to the app that raised it, then let
|
|
// that page's own handler run — it is the only thing that knows which
|
|
// message the notification was about.
|
|
listen<NotificationClick>("notification-clicked", async (e) => {
|
|
await api.focusWindow();
|
|
setSettingsOpen(false);
|
|
setActiveId(e.payload.appId);
|
|
await api.setActive(e.payload.appId);
|
|
await api.notificationClick(e.payload.appId, e.payload.notificationId);
|
|
}),
|
|
// Zoom changed from the menu bar; keep Settings' sliders honest.
|
|
listen<[string, number]>("zoom-changed", () => {
|
|
void api.getConfig().then(setConfig);
|
|
}),
|
|
listen<[string, number][]>("unread-changed", (e) => {
|
|
setUnread(Object.fromEntries(e.payload));
|
|
}),
|
|
// A login was submitted. The password is held in Rust; this only asks.
|
|
listen<PasswordOffer>("password-offer", (e) => setOffer(e.payload)),
|
|
];
|
|
return () => {
|
|
unlisten.forEach((p) => p.then((f) => f()));
|
|
};
|
|
}, []);
|
|
|
|
/* A native view paints over anything the shell draws, so a dialog needs the
|
|
app moved out of the way rather than merely a higher z-index — and once it
|
|
is moved there is nothing left behind the dialog to look at. A still taken
|
|
on the way out, blurred, puts the background back. */
|
|
useEffect(() => {
|
|
if (!config) return;
|
|
let cancelled = false;
|
|
|
|
if (settingsOpen) {
|
|
void (async () => {
|
|
const shot = await api.stageSnapshot().catch(() => null);
|
|
if (cancelled) return;
|
|
setBackdrop(shot);
|
|
await api.hideStage();
|
|
})();
|
|
} else {
|
|
setBackdrop(null);
|
|
void api.showStage();
|
|
}
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [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 (
|
|
<div className="flex h-screen">
|
|
<Nav
|
|
config={config}
|
|
activeId={activeId}
|
|
collapsed={collapsed}
|
|
onSelect={setActiveId}
|
|
onToggleCollapse={toggleCollapse}
|
|
onOpenSettings={() => { setFocusHidden(null); setSettingsOpen(true); }}
|
|
onToggleGroup={toggleGroup}
|
|
onBack={() => activeId && api.historyGo(activeId, -1)}
|
|
onForward={() => activeId && api.historyGo(activeId, 1)}
|
|
onReload={() => activeId && api.historyGo(activeId, 0)}
|
|
unread={unread}
|
|
/>
|
|
|
|
{/* The hole an app's native webview is positioned into. It stays empty
|
|
on purpose — anything drawn here would be painted over. */}
|
|
<div
|
|
ref={stageRef}
|
|
className="relative min-w-0 flex-1 overflow-hidden bg-slate-100 dark:bg-slate-950"
|
|
>
|
|
{backdrop && (
|
|
<img
|
|
src={backdrop}
|
|
alt=""
|
|
aria-hidden
|
|
/* Scaled up so the blur has no soft edge to give itself away. */
|
|
className="absolute inset-0 h-full w-full scale-110 object-cover blur-[16px]"
|
|
/>
|
|
)}
|
|
{config.apps.length === 0 && (
|
|
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
|
|
<p className="text-[13px] text-slate-500 dark:text-slate-400">
|
|
No apps yet. Add the tools you work in and they appear in the nav.
|
|
</p>
|
|
<button onClick={() => setSettingsOpen(true)} className={BTN_PRIMARY}>
|
|
Add your first app
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{offer && (
|
|
<Dialog
|
|
title="Save this password?"
|
|
onCancel={() => {
|
|
void api.discardPassword();
|
|
setOffer(null);
|
|
}}
|
|
confirmLabel="Save"
|
|
onConfirm={async () => {
|
|
try {
|
|
setSaved(await api.savePassword());
|
|
} catch {
|
|
setSaved(null);
|
|
}
|
|
setOffer(null);
|
|
}}
|
|
>
|
|
{offer.account ? (
|
|
<>
|
|
<span className="font-medium">{offer.account}</span> at{" "}
|
|
<span className="font-mono text-[12px]">{offer.host}</span>.
|
|
</>
|
|
) : (
|
|
<>
|
|
The login you just entered at{" "}
|
|
<span className="font-mono text-[12px]">{offer.host}</span>.
|
|
</>
|
|
)}{" "}
|
|
It goes into your macOS Keychain — not into this app's settings file, and
|
|
nowhere else on disk.
|
|
</Dialog>
|
|
)}
|
|
|
|
{saved && (
|
|
<Dialog title="Saved" onCancel={() => setSaved(null)}>
|
|
The password for <span className="font-mono text-[12px]">{saved}</span> is in
|
|
your Keychain. You can see and remove it in Keychain Access, under{" "}
|
|
<span className="font-mono text-[12px]">Work — {saved}</span>.
|
|
</Dialog>
|
|
)}
|
|
|
|
{settingsOpen && (
|
|
<Settings
|
|
config={config}
|
|
theme={theme}
|
|
activeId={activeId}
|
|
focusHiddenFor={focusHidden}
|
|
onConfig={setConfig}
|
|
onTheme={onTheme}
|
|
onClose={() => { setSettingsOpen(false); setFocusHidden(null); }}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|