diff --git a/src/renderer/components/layout/BrowserDrawerShell.tsx b/src/renderer/components/layout/BrowserDrawerShell.tsx new file mode 100644 index 00000000..67b3411c --- /dev/null +++ b/src/renderer/components/layout/BrowserDrawerShell.tsx @@ -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) { + if (visible) return; + if (event.propertyName !== "opacity") return; + setMounted(false); + onExited?.(); + } + + function handleResizeStart(event: ReactMouseEvent) { + 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 ( +
+
+
+ {!maximized ? ( +
+ ) : null} + {children} +
+ {isResizing ? ( +
+ ) : null} +
+ ); +} diff --git a/src/renderer/components/layout/OverlayShell.tsx b/src/renderer/components/layout/OverlayShell.tsx index a7b906fa..137704cb 100644 --- a/src/renderer/components/layout/OverlayShell.tsx +++ b/src/renderer/components/layout/OverlayShell.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; +import { pushEscapeHandler } from "./overlayEscapeStack"; export type OverlayShellMode = "fixed" | "absolute"; @@ -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 diff --git a/src/renderer/components/layout/overlayEscapeStack.ts b/src/renderer/components/layout/overlayEscapeStack.ts new file mode 100644 index 00000000..41c53177 --- /dev/null +++ b/src/renderer/components/layout/overlayEscapeStack.ts @@ -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); + }; +} diff --git a/src/renderer/state/panelStore.test.ts b/src/renderer/state/panelStore.test.ts new file mode 100644 index 00000000..70449c70 --- /dev/null +++ b/src/renderer/state/panelStore.test.ts @@ -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); + }); +}); diff --git a/src/renderer/state/panelStore.ts b/src/renderer/state/panelStore.ts index 0c1a8322..656aa1f8 100644 --- a/src/renderer/state/panelStore.ts +++ b/src/renderer/state/panelStore.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; import type { ThreadSortMode } from "@/renderer/views/MainView/parts/Sidebar/parts/sortMode"; +import { useFileEditorStore } from "./fileEditorStore"; export interface GitReviewContext { projectId: string; @@ -30,6 +31,8 @@ interface PanelState { rightPanelTab: RightPanelTab; browserPanelOpen: boolean; browserOverlayOpen: boolean; + browserOverlayMaximized: boolean; + browserOverlayDrawerWidth: number; settingsOpen: boolean; projectSettingsId: string | null; threadSortMode: ThreadSortMode; @@ -43,6 +46,8 @@ interface PanelState { setRightPanelTab: (tab: RightPanelTab) => void; setBrowserPanelOpen: (v: boolean) => void; setBrowserOverlayOpen: (v: boolean) => void; + setBrowserOverlayMaximized: (v: boolean) => void; + setBrowserOverlayDrawerWidth: (v: number) => void; openBrowserPanel: () => void; openSettings: () => void; closeSettings: () => void; @@ -54,6 +59,10 @@ interface PanelState { } const STORAGE_KEY = "lightcode-git-panel-context"; +const DRAWER_WIDTH_STORAGE_KEY = "lightcode-browser-drawer-width"; +const DEFAULT_DRAWER_WIDTH = 640; +const MIN_DRAWER_WIDTH = 420; +const MAX_DRAWER_WIDTH = 1400; function loadInitialGitContext(): GitReviewContext | null { try { @@ -64,6 +73,22 @@ function loadInitialGitContext(): GitReviewContext | null { } } +function clampDrawerWidth(v: number): number { + if (!Number.isFinite(v)) return DEFAULT_DRAWER_WIDTH; + return Math.max(MIN_DRAWER_WIDTH, Math.min(MAX_DRAWER_WIDTH, Math.round(v))); +} + +function loadInitialDrawerWidth(): number { + try { + const raw = localStorage.getItem(DRAWER_WIDTH_STORAGE_KEY); + if (raw === null) return DEFAULT_DRAWER_WIDTH; + const parsed = Number.parseInt(raw, 10); + return clampDrawerWidth(parsed); + } catch { + return DEFAULT_DRAWER_WIDTH; + } +} + export const usePanelStore = create((set) => ({ gitReviewContext: loadInitialGitContext(), gitReviewAsPanel: false, @@ -73,6 +98,8 @@ export const usePanelStore = create((set) => ({ rightPanelTab: "git", browserPanelOpen: false, browserOverlayOpen: false, + browserOverlayMaximized: false, + browserOverlayDrawerWidth: loadInitialDrawerWidth(), settingsOpen: false, projectSettingsId: null, threadSortMode: "updated", @@ -137,17 +164,37 @@ export const usePanelStore = create((set) => ({ set((state) => state.browserPanelOpen === v && (v || !state.browserOverlayOpen) ? {} - : { browserPanelOpen: v, ...(v ? {} : { browserOverlayOpen: false }) }, + : { + browserPanelOpen: v, + ...(v ? {} : { browserOverlayOpen: false, browserOverlayMaximized: false }), + }, ), + // NOTE: overlay state is intentionally independent of the right-panel + // browser. Opening the overlay does NOT enable the right-panel browser tab, + // and closing the overlay leaves the right panel in whatever state the user + // had it. Maximized resets on close so the next open lands in drawer mode. setBrowserOverlayOpen: (v) => set((state) => state.browserOverlayOpen === v ? {} : { browserOverlayOpen: v, - ...(v ? { browserPanelOpen: true, rightPanelTab: "browser" as const } : {}), + ...(v ? {} : { browserOverlayMaximized: false }), }, ), + setBrowserOverlayMaximized: (v) => + set((state) => (state.browserOverlayMaximized === v ? {} : { browserOverlayMaximized: v })), + setBrowserOverlayDrawerWidth: (v) => + set((state) => { + const clamped = clampDrawerWidth(v); + if (state.browserOverlayDrawerWidth === clamped) return {}; + try { + localStorage.setItem(DRAWER_WIDTH_STORAGE_KEY, String(clamped)); + } catch { + // localStorage may be unavailable (private mode, sandbox); fall back to in-memory. + } + return { browserOverlayDrawerWidth: clamped }; + }), openBrowserPanel: () => set((state) => state.browserPanelOpen && state.rightPanelTab === "browser" @@ -173,7 +220,8 @@ export const usePanelStore = create((set) => ({ state.gitReviewContext === null && state.filesPanelContext === null && !state.browserPanelOpen && - !state.browserOverlayOpen + !state.browserOverlayOpen && + !state.browserOverlayMaximized ) { return {}; } @@ -182,11 +230,31 @@ export const usePanelStore = create((set) => ({ filesPanelContext: null, browserPanelOpen: false, browserOverlayOpen: false, + browserOverlayMaximized: false, }; }); }, })); +// Returns true when any full-window overlay (z-50) is currently rendered above +// the right panel (z-10). Used by the browser sync layer to force the in-app +// browser into overlay mode (z-80) when a link is opened from within one of +// those overlays — otherwise the navigated page would render in the right +// panel, hidden behind the active overlay. Add new obstructing overlays here. +export function selectAnyObstructingOverlayOpen(): boolean { + const p = usePanelStore.getState(); + if ( + p.settingsOpen || + p.projectSettingsId !== null || + p.gitOverlayOpen || + p.prReviewContext !== null || + p.threadSearchOpen + ) { + return true; + } + return useFileEditorStore.getState().overlayMode === "fullscreen"; +} + // Narrow selectors — primitive returns, stable under Object.is. export function useGitReviewProjectId(): string | undefined { return usePanelStore((s) => s.gitReviewContext?.projectId); diff --git a/src/renderer/views/LoginTerminalOverlay/LoginTerminalOverlay.tsx b/src/renderer/views/LoginTerminalOverlay/LoginTerminalOverlay.tsx index 6ebe3a43..c72d4bfb 100644 --- a/src/renderer/views/LoginTerminalOverlay/LoginTerminalOverlay.tsx +++ b/src/renderer/views/LoginTerminalOverlay/LoginTerminalOverlay.tsx @@ -80,8 +80,9 @@ export function LoginTerminalOverlay() { // (Gemini's auth picker, oauth-personal cancel, etc.). The X button is the // only way to dismiss this overlay. - function handleTransitionEnd() { + function handleTransitionEnd(event: React.TransitionEvent) { if (visible) return; + if (event.propertyName !== "transform") return; if (renderedSession) { // Animation finished out — release the xterm. The shell, if still alive, // is closed by closeSession; this only happens when the store cleared @@ -93,7 +94,13 @@ export function LoginTerminalOverlay() { if (!renderedSession) return null; return ( -
+
+
s.browserOverlayMaximized); const setBrowserOverlayOpen = usePanelStore((s) => s.setBrowserOverlayOpen); return ( - setBrowserOverlayOpen(false)}> + setBrowserOverlayOpen(false)} + > - + ); } diff --git a/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx b/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx index 4381dd8b..e79c6020 100644 --- a/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +++ b/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { Minimize2 } from "lucide-react"; +import { Maximize2, Minimize2, X } from "lucide-react"; import { isMac, readBridge } from "@/renderer/bridge"; import { useBrowserPanelStore } from "@/renderer/state/browserPanelStore"; import { usePanelStore } from "@/renderer/state/panelStore"; @@ -19,7 +19,9 @@ export function BrowserPanel(props: { visible: boolean }) { const tabs = useBrowserPanelStore((s) => s.tabs); const activeTabId = useBrowserPanelStore((s) => s.activeTabId); const browserOverlayOpen = usePanelStore((s) => s.browserOverlayOpen); + const browserOverlayMaximized = usePanelStore((s) => s.browserOverlayMaximized); const setBrowserOverlayOpen = usePanelStore((s) => s.setBrowserOverlayOpen); + const setBrowserOverlayMaximized = usePanelStore((s) => s.setBrowserOverlayMaximized); const visible = props.visible || browserOverlayOpen; const [menuPreviewDataUrl, setMenuPreviewDataUrl] = useState(null); const { @@ -61,29 +63,55 @@ export function BrowserPanel(props: { visible: boolean }) { return () => clearTimeout(timer); }, [createTab, visible, tabs.length]); + const isFullscreenOverlay = browserOverlayOpen && browserOverlayMaximized; + const headerButtonClass = `${ + isFullscreenOverlay ? "lightcode-overlay-header__controls " : "" + }${panelHeaderIconButtonClass}`; return ( -
+
{browserOverlayOpen ? (
- {isMac() ?
: null} + {isMac() && isFullscreenOverlay ? ( +
+ ) : null}
Browser
+ {browserOverlayMaximized ? ( + + ) : ( + + )}
) : null} diff --git a/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/hooks/useBrowserSync.ts b/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/hooks/useBrowserSync.ts index 82277214..fd54566c 100644 --- a/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/hooks/useBrowserSync.ts +++ b/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/hooks/useBrowserSync.ts @@ -1,7 +1,7 @@ import { useEffect } from "react"; import { readBridge } from "@/renderer/bridge"; import { useBrowserPanelStore } from "@/renderer/state/browserPanelStore"; -import { usePanelStore } from "@/renderer/state/panelStore"; +import { selectAnyObstructingOverlayOpen, usePanelStore } from "@/renderer/state/panelStore"; export function useBrowserSync(): void { const setState = useBrowserPanelStore((s) => s.setState); @@ -20,7 +20,12 @@ export function useBrowserSync(): void { setAttention(event.tabId); } else if (event.type === "open-panel") { const panel = usePanelStore.getState(); - if (event.mode === "overlay") { + const wantsFullscreen = event.mode === "overlay"; + if (wantsFullscreen || selectAnyObstructingOverlayOpen()) { + // Float the overlay above any active z-50 surface. Fullscreen when the + // user explicitly chose "overlay" presentation, drawer (z-60) when + // forced because an obstructing overlay would otherwise hide the page. + panel.setBrowserOverlayMaximized(wantsFullscreen); panel.setBrowserOverlayOpen(true); } else { if (event.mode === "panel") {