Files
work-app/src/App.tsx
T
Vincent 1b6dabab0a Unread counts, a window margin, blurred dialogs, softer group names
Each rise in an app's unread count while you are elsewhere adds to a
tally beside its name, and a dot on its icon in the rail. Looking at the
app is the only thing that clears it.

The window has no frame of its own, so the shell now keeps a 6px margin
in a normal window - the only part of it that is not a web page, and so
the only place left to grab. Full screen gives the room back.

Dialogs get the app blurred behind them. An app's webview paints above
the shell, so it has to be moved aside before a dialog can be seen at
all, and once moved there is nothing left to blur - a still taken on the
way out is the only way to keep the background there. It runs on a
blocking worker: its completion handler is on the main thread, and
waiting there deadlocks until the timeout and returns nothing.

Settings is much larger, the cog moved to the foot of the nav, group
names read as names rather than shouted headings, and "Refresh icons"
bumps a version every favicon URL carries, for the ones that cache wrong.
2026-09-01 14:31:51 +02:00

249 lines
8.3 KiB
TypeScript

import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
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, NotificationClick, 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 [fullscreen, setFullscreen] = useState(false);
const [backdrop, setBackdrop] = 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;
/* The window has no frame of its own, so in a normal window the shell keeps
a margin around itself: somewhere to grab that is not a web page, and the
only way to move or place the window by hand. Full screen has no use for
it and gives the room back. */
useEffect(() => {
const w = getCurrentWindow();
const check = () => void w.isFullscreen().then(setFullscreen);
check();
const un = w.onResized(check);
return () => {
void un.then((f) => f());
};
}, []);
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<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));
}),
];
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;
const frame = fullscreen ? 0 : 6;
return (
<div
data-tauri-drag-region
className="flex h-screen bg-slate-200 dark:bg-black"
style={{ padding: frame }}
>
<div
className={
"flex min-w-0 flex-1 overflow-hidden " +
(fullscreen ? "" : "rounded-xl border border-slate-300 dark:border-slate-800")
}
>
<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>
</div>
{settingsOpen && (
<Settings
config={config}
theme={theme}
activeId={activeId}
focusHiddenFor={focusHidden}
onConfig={setConfig}
onTheme={onTheme}
onClose={() => { setSettingsOpen(false); setFocusHidden(null); }}
/>
)}
</div>
);
}