Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions src/renderer/components/layout/BrowserDrawerShell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import {
useEffect,
useState,
type CSSProperties,
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from "react";
import { usePanelStore } from "@/renderer/state/panelStore";
import { pushEscapeHandler } from "./overlayEscapeStack";

/**
* Floating shell for the in-app browser overlay. Reuses the chrome of
* LoginTerminalOverlay (rounded floating card, same margins, border, shadow)
* and hosts both presentation modes — drawer and fullscreen — on a single
* mounted element so toggling maximize transitions size/position smoothly
* instead of unmounting and replaying the entrance animation.
*
* Behavior:
* - Animation: subtle slide-in combined with fade. Works for both drawer and
* fullscreen without the jarring full-width slide of fullscreen.
* - Maximized: leaves side margins so macOS traffic lights and Windows
* titleBarOverlay controls (top-left/top-right) sit outside the panel.
* The backdrop also leaves the titlebar strip exposed so OS controls and
* window-drag region stay live.
* - Backdrop: semi-transparent scrim outside the panel — dims the underlying
* overlay and consumes pointer events. Click to dismiss.
* - Resize: left-edge drag handle adjusts width in drawer mode. While
* dragging, a full-window cursor catcher sits above the embedded webview so
* pointer events keep flowing to the host window (webview otherwise eats
* them as soon as the cursor crosses into its area).
* - Escape: routed through the shared overlay escape stack so the underlying
* overlay below this one is not also dismissed.
*/
export function BrowserDrawerShell(props: {
open: boolean;
maximized: boolean;
onExited?: () => void;
children: ReactNode;
}) {
const { open, maximized, onExited, children } = props;
const drawerWidth = usePanelStore((s) => s.browserOverlayDrawerWidth);
const setDrawerWidth = usePanelStore((s) => s.setBrowserOverlayDrawerWidth);
const [mounted, setMounted] = useState(open);
const [visible, setVisible] = useState(false);
const [isResizing, setIsResizing] = useState(false);

useEffect(() => {
if (open) {
setMounted(true);
let inner = 0;
const outer = requestAnimationFrame(() => {
inner = requestAnimationFrame(() => setVisible(true));
});
return () => {
cancelAnimationFrame(outer);
if (inner) cancelAnimationFrame(inner);
};
}
setVisible(false);
}, [open]);

function requestClose() {
setVisible(false);
(document.activeElement as HTMLElement | null)?.blur();
}

useEffect(() => {
if (!open || !onExited) return;
return pushEscapeHandler(requestClose);
// requestClose closes over stable setters/refs.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, onExited]);

function handleTransitionEnd(event: React.TransitionEvent<HTMLDivElement>) {
if (visible) return;
if (event.propertyName !== "opacity") return;
setMounted(false);
onExited?.();
}

function handleResizeStart(event: ReactMouseEvent<HTMLDivElement>) {
event.preventDefault();
const startX = event.clientX;
const startWidth = drawerWidth;
document.body.style.userSelect = "none";
document.body.style.cursor = "ew-resize";
setIsResizing(true);

function onMove(e: MouseEvent) {
// Handle is on the LEFT edge of a panel anchored to the RIGHT viewport
// edge; dragging left (negative clientX delta) grows the panel.
const delta = startX - e.clientX;
setDrawerWidth(startWidth + delta);
}
function onUp() {
document.body.style.userSelect = "";
document.body.style.cursor = "";
setIsResizing(false);
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
}
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
}

if (!mounted) return null;

const maximizedOverrides: CSSProperties = maximized
? {
top: 0,
right: 0,
bottom: 0,
width: "100vw",
maxWidth: "none",
borderRadius: 0,
}
: { width: `${drawerWidth}px` };

const animationTransition = isResizing
? "transform 240ms ease-out, opacity 240ms ease-out, top 200ms ease-out, right 200ms ease-out, bottom 200ms ease-out, max-width 200ms ease-out, border-radius 200ms ease-out"
: "transform 240ms ease-out, opacity 240ms ease-out, top 200ms ease-out, right 200ms ease-out, bottom 200ms ease-out, width 200ms ease-out, max-width 200ms ease-out, border-radius 200ms ease-out";

return (
<div className={`fixed inset-0 ${maximized ? "z-[80]" : "z-[60]"}`}>
<div
className="absolute inset-0 bg-black/40 transition-opacity duration-200"
style={{ opacity: visible ? 1 : 0 }}
onClick={requestClose}
aria-hidden
/>
<div
data-overlay-surface="browser-overlay"
className="pointer-events-auto fixed bottom-8 right-8 top-8 flex max-w-[calc(100vw-4rem)] flex-col overflow-hidden rounded-3xl border border-border bg-background shadow-2xl will-change-transform"
style={{
...maximizedOverrides,
transform: visible ? "translateX(0)" : "translateX(120px)",
opacity: visible ? 1 : 0,
transition: animationTransition,
}}
onTransitionEnd={handleTransitionEnd}
>
{!maximized ? (
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize browser drawer"
className="absolute left-0 top-0 bottom-0 z-10 w-1.5 cursor-ew-resize transition-colors hover:bg-foreground/15"
onMouseDown={handleResizeStart}
/>
) : null}
{children}
</div>
{isResizing ? (
<div className="absolute inset-0 z-[100]" style={{ cursor: "ew-resize" }} aria-hidden />
) : null}
</div>
);
}
21 changes: 9 additions & 12 deletions src/renderer/components/layout/OverlayShell.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { pushEscapeHandler } from "./overlayEscapeStack";

export type OverlayShellMode = "fixed" | "absolute";

Expand Down Expand Up @@ -38,20 +39,16 @@ export function OverlayShell(props: {
setVisible(false);
}, [open]);

// Close on Escape key — triggers fade-out, then onExited resets parent state
// Close on Escape via the overlay escape stack — only the topmost overlay
// dismisses, so a transient overlay floating above this one (e.g. the
// browser drawer at z-60 above Settings at z-50) consumes Escape first.
useEffect(() => {
if (!open || !onExited) return;
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
escapeClosingRef.current = true;
setVisible(false);
(document.activeElement as HTMLElement | null)?.blur();
}
}
window.addEventListener("keydown", onKeyDown, { capture: true });
return () => window.removeEventListener("keydown", onKeyDown, { capture: true });
return pushEscapeHandler(() => {
escapeClosingRef.current = true;
setVisible(false);
(document.activeElement as HTMLElement | null)?.blur();
});
}, [open, onExited]);

// Unmount after fade-out transition completes
Expand Down
35 changes: 35 additions & 0 deletions src/renderer/components/layout/overlayEscapeStack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Process-wide stack for overlay Escape handling. Only the topmost overlay
* dismisses on a given Escape press, so the browser drawer floating above
* Settings closes the drawer first and leaves Settings intact.
*
* Each overlay pushes a close handler on mount/open and pops it on
* unmount/close. A single window-level capture listener routes the keypress
* to the top of the stack.
*/

type Handler = () => void;

const stack: Handler[] = [];
let listenerInstalled = false;

function onKeyDown(event: KeyboardEvent): void {
if (event.key !== "Escape") return;
const handler = stack[stack.length - 1];
if (!handler) return;
event.preventDefault();
event.stopPropagation();
handler();
}

export function pushEscapeHandler(handler: Handler): () => void {
if (!listenerInstalled) {
window.addEventListener("keydown", onKeyDown, { capture: true });
listenerInstalled = true;
}
stack.push(handler);
return () => {
const idx = stack.lastIndexOf(handler);
if (idx >= 0) stack.splice(idx, 1);
};
}
130 changes: 130 additions & 0 deletions src/renderer/state/panelStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { selectAnyObstructingOverlayOpen, usePanelStore } from "./panelStore";
import { useFileEditorStore } from "./fileEditorStore";

const initialPanelState = usePanelStore.getState();
const initialFileEditorState = useFileEditorStore.getState();

function resetPanelStore() {
usePanelStore.setState({
...initialPanelState,
gitReviewContext: null,
gitReviewAsPanel: false,
gitOverlayOpen: false,
prReviewContext: null,
filesPanelContext: null,
browserPanelOpen: false,
browserOverlayOpen: false,
settingsOpen: false,
projectSettingsId: null,
threadSearchOpen: false,
});
}

function resetFileEditorStore() {
useFileEditorStore.setState({
...initialFileEditorState,
overlayMode: null,
});
}

describe("selectAnyObstructingOverlayOpen", () => {
beforeEach(() => {
resetPanelStore();
resetFileEditorStore();
});
afterEach(() => {
resetPanelStore();
resetFileEditorStore();
});

it("returns false when no overlays are open", () => {
expect(selectAnyObstructingOverlayOpen()).toBe(false);
});

it("returns true when the settings overlay is open", () => {
usePanelStore.setState({ settingsOpen: true });
expect(selectAnyObstructingOverlayOpen()).toBe(true);
});

it("returns true when a project settings overlay is open", () => {
usePanelStore.setState({ projectSettingsId: "proj-1" });
expect(selectAnyObstructingOverlayOpen()).toBe(true);
});

it("returns true when the git review overlay is open", () => {
usePanelStore.setState({ gitOverlayOpen: true });
expect(selectAnyObstructingOverlayOpen()).toBe(true);
});

it("returns true when a PR review context is set", () => {
usePanelStore.setState({
prReviewContext: { projectId: "p", prNumber: 1 },
});
expect(selectAnyObstructingOverlayOpen()).toBe(true);
});

it("returns true when the thread search overlay is open", () => {
usePanelStore.setState({ threadSearchOpen: true });
expect(selectAnyObstructingOverlayOpen()).toBe(true);
});

it("returns true when the file editor overlay is fullscreen", () => {
useFileEditorStore.setState({ overlayMode: "fullscreen" });
expect(selectAnyObstructingOverlayOpen()).toBe(true);
});

it("does not treat gitReviewAsPanel as obstructing on its own", () => {
usePanelStore.setState({ gitReviewAsPanel: true });
expect(selectAnyObstructingOverlayOpen()).toBe(false);
});

it("does not treat the browser overlay itself as obstructing", () => {
usePanelStore.setState({ browserOverlayOpen: true, browserPanelOpen: true });
expect(selectAnyObstructingOverlayOpen()).toBe(false);
});
});

describe("browserOverlayMaximized lifecycle", () => {
beforeEach(() => {
resetPanelStore();
});
afterEach(() => {
resetPanelStore();
});

it("defaults to false so the overlay opens in drawer mode", () => {
expect(usePanelStore.getState().browserOverlayMaximized).toBe(false);
});

it("is reset to false when the overlay is closed", () => {
const { setBrowserOverlayOpen, setBrowserOverlayMaximized } = usePanelStore.getState();
setBrowserOverlayOpen(true);
setBrowserOverlayMaximized(true);
expect(usePanelStore.getState().browserOverlayMaximized).toBe(true);

setBrowserOverlayOpen(false);
expect(usePanelStore.getState().browserOverlayMaximized).toBe(false);
});

it("is reset when the browser panel is closed entirely", () => {
const { setBrowserOverlayOpen, setBrowserOverlayMaximized, setBrowserPanelOpen } =
usePanelStore.getState();
setBrowserOverlayOpen(true);
setBrowserOverlayMaximized(true);

setBrowserPanelOpen(false);
expect(usePanelStore.getState().browserOverlayMaximized).toBe(false);
expect(usePanelStore.getState().browserOverlayOpen).toBe(false);
});

it("is reset by closeAllPanels", () => {
const { setBrowserOverlayOpen, setBrowserOverlayMaximized, closeAllPanels } =
usePanelStore.getState();
setBrowserOverlayOpen(true);
setBrowserOverlayMaximized(true);

closeAllPanels();
expect(usePanelStore.getState().browserOverlayMaximized).toBe(false);
});
});
Loading