98 lines
3.6 KiB
TypeScript
98 lines
3.6 KiB
TypeScript
import { useCanGoBack, useRouter } from "@tanstack/react-router";
|
|
import { ArrowLeft, ArrowRight, PanelLeft } from "lucide-react";
|
|
import { useEffect, useState } from "react";
|
|
import { useUiStore } from "../stores/ui-store";
|
|
|
|
const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
|
|
const noDragStyle = isMac ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperties) : undefined;
|
|
|
|
// macOS-only titlebar cluster (sidebar toggle + history arrows) pinned beside
|
|
// the traffic lights, VS Code-style. Approved divergence from the web
|
|
// reference, which has no window chrome (DESIGN.md banner, 2026-06-10).
|
|
// Rendered once by the shell as a fixed overlay (.titlebar-nav in styles.css)
|
|
// over the full-width topbar's left inset, so the buttons occupy the exact
|
|
// same spot whether the sidebar is expanded or collapsed; the topbar starts
|
|
// its content past the cluster (.is-under-titlebar-nav).
|
|
// The installed router has no useCanGoForward, and deriving one as
|
|
// `__TSR_index < history.length - 1` (the upstream hook's approach) is wrong
|
|
// here: window.history.length also counts entries the router never created —
|
|
// the WebContents' initial blank entry, pre-router loads — so the tip of the
|
|
// stack still reads as "forward available" and the arrow no-ops. Instead,
|
|
// track the highest router index reachable on the live stack: a PUSH discards
|
|
// the forward entries (the new index is the tip); BACK/FORWARD/GO only move
|
|
// within it. After a mid-stack reload the tip resets to the current entry —
|
|
// forward greys out rather than dangle on entries we can no longer see.
|
|
function useCanGoForward(): boolean {
|
|
const router = useRouter();
|
|
const [canGoForward, setCanGoForward] = useState(false);
|
|
useEffect(() => {
|
|
let tip = router.history.location.state.__TSR_index;
|
|
return router.history.subscribe(({ location, action }) => {
|
|
const index = location.state.__TSR_index;
|
|
tip = action.type === "PUSH" ? index : Math.max(tip, index);
|
|
setCanGoForward(index < tip);
|
|
});
|
|
}, [router]);
|
|
return canGoForward;
|
|
}
|
|
|
|
export function TitlebarNav() {
|
|
const { isSidebarOpen, toggleSidebar } = useUiStore();
|
|
const router = useRouter();
|
|
const canGoBack = useCanGoBack();
|
|
const canGoForward = useCanGoForward();
|
|
|
|
if (!isMac) return null;
|
|
|
|
return (
|
|
<div className="titlebar-nav" style={noDragStyle}>
|
|
<TitlebarButton
|
|
label={isSidebarOpen ? "Collapse sidebar" : "Expand sidebar"}
|
|
onClick={toggleSidebar}
|
|
title={`${isSidebarOpen ? "Collapse" : "Expand"} sidebar · ⌘B`}
|
|
>
|
|
<PanelLeft className="h-[15px] w-[15px]" aria-hidden="true" />
|
|
</TitlebarButton>
|
|
<TitlebarButton disabled={!canGoBack} label="Go back" onClick={() => router.history.back()} title="Go back">
|
|
<ArrowLeft className="h-[15px] w-[15px]" aria-hidden="true" />
|
|
</TitlebarButton>
|
|
<TitlebarButton
|
|
disabled={!canGoForward}
|
|
label="Go forward"
|
|
onClick={() => router.history.forward()}
|
|
title="Go forward"
|
|
>
|
|
<ArrowRight className="h-[15px] w-[15px]" aria-hidden="true" />
|
|
</TitlebarButton>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TitlebarButton({
|
|
label,
|
|
title,
|
|
disabled,
|
|
onClick,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
title: string;
|
|
disabled?: boolean;
|
|
onClick: () => void;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<button
|
|
aria-label={label}
|
|
className="titlebar-nav__btn grid place-items-center rounded-md text-passive transition-colors hover:bg-interactive-hover hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-45"
|
|
disabled={disabled}
|
|
onClick={onClick}
|
|
style={noDragStyle}
|
|
title={title}
|
|
type="button"
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|