Work App: a dedicated browser for work tools
A Tauri 2 shell with one child webview per configured tool. Nav on the left with groups and a collapsible icon rail; links between configured apps switch tabs, everything else leaves for the real browser. Design spec in docs/superpowers/specs/2026-09-01-work-app-design.md.
This commit is contained in:
+179
@@ -0,0 +1,179 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
import * as api from "./api";
|
||||
import Nav, { navWidth } from "./components/Nav";
|
||||
import Settings from "./components/Settings";
|
||||
import TopBar from "./components/TopBar";
|
||||
import { BTN_PRIMARY } from "./components/ui";
|
||||
import { useAppearance, type Theme } from "./hooks/useAppearance";
|
||||
import type { Config, Group, SwitchEvent, UrlEvent } from "./types";
|
||||
|
||||
export default function App() {
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [theme, setTheme] = useAppearance("system");
|
||||
|
||||
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;
|
||||
const activeApp = config?.apps.find((a) => a.id === activeId) ?? null;
|
||||
|
||||
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) return;
|
||||
void api.setActive(activeId);
|
||||
void api.currentUrl(activeId).then(setUrl);
|
||||
}, [activeId]);
|
||||
|
||||
// A link in one app that points at another: Rust decided, the shell moves.
|
||||
useEffect(() => {
|
||||
const unlisten = [
|
||||
listen<SwitchEvent>("switch-app", async (e) => {
|
||||
setActiveId(e.payload.appId);
|
||||
await api.navigateApp(e.payload.appId, e.payload.url);
|
||||
}),
|
||||
listen<UrlEvent>("url-changed", (e) => {
|
||||
if (e.payload.appId === activeRef.current) setUrl(e.payload.url);
|
||||
}),
|
||||
];
|
||||
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 (
|
||||
<div className="flex h-screen flex-col">
|
||||
<TopBar
|
||||
url={url}
|
||||
appName={activeApp?.name ?? null}
|
||||
onBack={() => activeId && api.historyGo(activeId, -1)}
|
||||
onForward={() => activeId && api.historyGo(activeId, 1)}
|
||||
onReload={() => activeId && api.historyGo(activeId, 0)}
|
||||
onOpenExternal={() => url && api.openExternal(url)}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<Nav
|
||||
config={config}
|
||||
activeId={activeId}
|
||||
collapsed={collapsed}
|
||||
onSelect={setActiveId}
|
||||
onToggleCollapse={toggleCollapse}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onToggleGroup={toggleGroup}
|
||||
/>
|
||||
|
||||
{/* 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="min-w-0 flex-1 bg-slate-100 dark:bg-slate-950">
|
||||
{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}
|
||||
onConfig={setConfig}
|
||||
onTheme={onTheme}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Keeps the nav width honest for the stage measurement above. */}
|
||||
<span hidden>{navWidth(collapsed)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user