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
29 changes: 29 additions & 0 deletions src/renderer/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1579,6 +1579,35 @@ html[data-platform="darwin"] .poracode-content-over-drag-region--drag {
flex-shrink: 0;
}

/* Right / git panel floating as a right-edge overlay: the handle sits inside the
fixed aside (which clips its children), so it cannot straddle the shared
border with negative margins. Keep the hit area inside the panel's left
gutter and draw the hover accent over the border itself. */
.poracode-resize-handle-overlay {
position: absolute;
bottom: 0;
left: 0;
width: 8px;
cursor: col-resize;
z-index: 20;
}

.poracode-resize-handle-overlay::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 2px;
background: transparent;
transition: background 0.15s;
}

.poracode-resize-handle-overlay:hover::after,
.poracode-resize-handle-overlay:focus-visible::after {
background: var(--accent);
}

/* Right / git / bottom terminal: [handle][aside] or [horizontal handle][aside] */
.poracode-resize-handle[aria-label="Resize terminal panel"]:hover + aside,
.poracode-resize-handle[aria-label="Resize git panel"]:hover + aside {
Expand Down
39 changes: 30 additions & 9 deletions src/renderer/views/MainView/parts/AppShell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
} from "@/renderer/state/sidebarOverlayStore";
import {
CONTENT_MIN_WIDTH,
type ResizeLimits,
type ResizeTarget,
SIDEBAR_MIN_WIDTH,
useResizablePanels,
} from "./parts/useResizablePanels";
Expand Down Expand Up @@ -396,15 +398,20 @@ export function AppShell(props: {
handlePanelResizeKeyDown,
handlePanelBottomResizeKeyDown,
handleGitPanelResizeKeyDown,
} = useResizablePanels({
sidebarRef,
panelRef,
panelInnerRef,
gitPanelRef,
gitPanelInnerRef,
mainRef,
overlayRef: resizeOverlayRef,
});
} = useResizablePanels(
{
sidebarRef,
panelRef,
panelInnerRef,
gitPanelRef,
gitPanelInnerRef,
mainRef,
overlayRef: resizeOverlayRef,
},
// Hoisted so it can read the overlay geometry computed further down; only
// ever called during a drag/nudge, never while rendering.
{ getResizeLimits },
);

const onRequestClosePanels = props.onRequestClosePanels;
const onDismissRightOverlay = props.onDismissRightOverlay ?? onRequestClosePanels;
Expand Down Expand Up @@ -537,6 +544,20 @@ export function AppShell(props: {
const rightPanelAsOverlay = rightOverlayDisplayed && displayedRightOverlaySlot === "right";
const gitPanelAsOverlay = rightOverlayDisplayed && displayedRightOverlaySlot === "git";

// Overlay panels are resizable too, but with their own bounds: capped by the
// gutter (above) and floored just above the width at which the panel would
// dock again. Re-docking mid-drag would swap the docked/overlay <aside> under
// the cursor and drop the drag, so a resize must never flip the mode. The
// floor ignores a second open panel's width, which only makes it stricter.
const overlayDockFloor = shellWidth - observedSidebarWidth - CONTENT_MIN_WIDTH + 1;
function getResizeLimits(target: ResizeTarget): ResizeLimits | null {
if (!rightOverlayActive || overlayMaxWidth === undefined) return null;
const isOverlaySlot =
(target === "panel" && rightPanelAsOverlay) || (target === "git-panel" && gitPanelAsOverlay);
if (!isOverlaySlot) return null;
return { min: overlayDockFloor, max: overlayMaxWidth };
}

return (
<div
ref={shellRef}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { createRef } from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AsideSlot } from "./AsideSlot";

function renderSlot(overrides: {
overlay?: boolean;
isOpen?: boolean;
onResizeStart?: () => void;
}) {
const onResizeStart = overrides.onResizeStart ?? vi.fn<() => void>();
render(
<AsideSlot
orientation="vertical"
isOpen={overrides.isOpen ?? true}
targetWidth={480}
onResizeStart={onResizeStart}
onResizeKeyDown={vi.fn<() => void>()}
panelRef={createRef<HTMLDivElement>()}
panelInnerRef={createRef<HTMLDivElement>()}
ariaLabel="Resize terminal panel"
overlay={overrides.overlay ?? false}
overlayReady={overrides.overlay ?? false}
>
<div>panel body</div>
</AsideSlot>,
);
return { onResizeStart };
}

describe("AsideSlot", () => {
it("keeps the resize handle beside the panel when docked", () => {
renderSlot({ overlay: false });
const handle = screen.getByRole("separator", { name: "Resize terminal panel" });
expect(handle.className).toContain("poracode-resize-handle");
expect(handle.nextElementSibling?.tagName).toBe("ASIDE");
});

it("renders the resize handle inside the panel when floating as an overlay", () => {
const { onResizeStart } = renderSlot({ overlay: true });
const handle = screen.getByRole("separator", { name: "Resize terminal panel" });
expect(handle.className).toContain("poracode-resize-handle-overlay");
expect(handle.closest("aside")).not.toBeNull();

fireEvent.mouseDown(handle);
expect(onResizeStart).toHaveBeenCalledTimes(1);
});

it("hides the overlay resize handle while the panel is closed", () => {
renderSlot({ overlay: true, isOpen: false });
expect(screen.queryByRole("separator")).toBeNull();
});
});
17 changes: 17 additions & 0 deletions src/renderer/views/MainView/parts/AppShell/parts/AsideSlot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export function AsideSlot(props: {

const isHorizontal = orientation === "horizontal";
const showHandle = isOpen && !overlay;
// The overlay panel is `position: fixed` and clips its children, so its handle
// lives *inside* the aside on the left edge instead of as a flex sibling —
// that way it slides with the panel and needs no separate positioning.
const showOverlayHandle = isOpen && overlay && !isHorizontal;

// Docked path: width/height animates open <-> closed.
const dockedDisplayWidth = !isHorizontal ? (isOpen ? targetWidth : 0) : undefined;
Expand Down Expand Up @@ -128,6 +132,19 @@ export function AsideSlot(props: {
/>
)}
<aside key={asideKey} ref={panelRef} className={asideClassName} style={asideStyle}>
{showOverlayHandle && (
<div
key="overlay-handle"
className="poracode-resize-handle-overlay"
style={{ top: overlayTop }}
onMouseDown={onResizeStart}
onKeyDown={onResizeKeyDown}
role="separator"
tabIndex={0}
aria-orientation={orientation}
aria-label={ariaLabel}
/>
)}
<div ref={panelInnerRef} className="h-full w-full" style={innerStyle}>
{children}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,44 @@ export const CONTENT_MIN_WIDTH = 540;

export type ResizeTarget = "sidebar" | "panel" | "panel-bottom" | "git-panel";

export function useResizablePanels(refs: {
sidebarRef: RefObject<HTMLDivElement | null>;
panelRef: RefObject<HTMLDivElement | null>;
panelInnerRef: RefObject<HTMLDivElement | null>;
gitPanelRef: RefObject<HTMLDivElement | null>;
gitPanelInnerRef: RefObject<HTMLDivElement | null>;
mainRef: RefObject<HTMLElement | null>;
overlayRef: RefObject<HTMLDivElement | null>;
}) {
/**
* Caller-supplied bounds for one drag, overriding the built-in range. Used for
* right-side panels floating as an overlay: main content is not squeezed at
* all there, so the docked "keep main >= CONTENT_MIN_WIDTH" cap does not apply.
*/
export interface ResizeLimits {
min?: number;
max?: number;
}

/**
* Resolves the allowed size range for one drag/nudge. `limits` (from the
* caller) wins over `fallbackMax` (the built-in dynamic cap), and both stay
* inside the target's hard bounds. `max` is never below `min`.
*/
function resolveRange(
hardMin: number,
hardMax: number,
limits: ResizeLimits | null,
fallbackMax: number = hardMax,
): { min: number; max: number } {
const min = Math.max(hardMin, Math.ceil(limits?.min ?? hardMin));
const max = Math.min(hardMax, Math.floor(limits?.max ?? fallbackMax));
return { min, max: Math.max(min, max) };
}

export function useResizablePanels(
refs: {
sidebarRef: RefObject<HTMLDivElement | null>;
panelRef: RefObject<HTMLDivElement | null>;
panelInnerRef: RefObject<HTMLDivElement | null>;
gitPanelRef: RefObject<HTMLDivElement | null>;
gitPanelInnerRef: RefObject<HTMLDivElement | null>;
mainRef: RefObject<HTMLElement | null>;
overlayRef: RefObject<HTMLDivElement | null>;
},
options?: { getResizeLimits?: (target: ResizeTarget) => ResizeLimits | null },
) {
const [sidebarWidth, setSidebarWidth] = useState(() =>
readStoredNumber("poracode-sidebar-width", SIDEBAR_DEFAULT_WIDTH),
);
Expand All @@ -54,6 +83,13 @@ export function useResizablePanels(refs: {
gitPanelWidth,
});

// Read at drag/nudge time (never during render), so the latest closure is
// enough and `startResize` keeps a stable identity.
const getResizeLimitsRef = useRef(options?.getResizeLimits);
useEffect(() => {
getResizeLimitsRef.current = options?.getResizeLimits;
});

useEffect(() => {
sizeRef.current = {
sidebarWidth,
Expand Down Expand Up @@ -193,22 +229,17 @@ export function useResizablePanels(refs: {

// Cap right-side panel drags so main content never falls below
// CONTENT_MIN_WIDTH — otherwise the auto-hide ResizeObserver kicks in
// mid-drag and the panel disappears under the cursor.
// mid-drag and the panel disappears under the cursor. A floating overlay
// panel does not squeeze main at all, so the caller overrides the range.
const limits = getResizeLimitsRef.current?.(target) ?? null;
const mainW = refs.mainRef.current?.getBoundingClientRect().width ?? 0;
const dynamicMaxPanel =
target === "panel" && mainW > 0
? Math.min(
PANEL_MAX_WIDTH,
Math.max(PANEL_MIN_WIDTH, mainW + startWidth - CONTENT_MIN_WIDTH),
)
: PANEL_MAX_WIDTH;
const dynamicMaxGitPanel =
target === "git-panel" && mainW > 0
? Math.min(
GIT_PANEL_MAX_WIDTH,
Math.max(GIT_PANEL_MIN_WIDTH, mainW + startWidth - CONTENT_MIN_WIDTH),
)
: GIT_PANEL_MAX_WIDTH;
const dockedMaxWidth =
mainW > 0 ? mainW + startWidth - CONTENT_MIN_WIDTH : Number.POSITIVE_INFINITY;
// Only meaningful for the "panel" / "git-panel" targets.
const sidePanelRange =
target === "git-panel"
? resolveRange(GIT_PANEL_MIN_WIDTH, GIT_PANEL_MAX_WIDTH, limits, dockedMaxWidth)
: resolveRange(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH, limits, dockedMaxWidth);

// The element whose CSS transition must be paused for the duration of the drag,
// otherwise its width/height will lag behind the per-frame ref writes below.
Expand Down Expand Up @@ -247,12 +278,23 @@ export function useResizablePanels(refs: {
if (next === sizeRef.current.sidebarWidth) return;
sizeRef.current.sidebarWidth = next;
applySidebarWidth(next);
} else if (target === "panel") {
} else if (target === "panel" || target === "git-panel") {
// Both right-side panels are anchored to the right edge and share the
// same clamp; only the state field and DOM writer differ.
const delta = startX - x;
const next = Math.min(dynamicMaxPanel, Math.max(PANEL_MIN_WIDTH, startWidth + delta));
if (next === sizeRef.current.panelWidth) return;
sizeRef.current.panelWidth = next;
applyPanelWidth(next);
const next = Math.min(
sidePanelRange.max,
Math.max(sidePanelRange.min, startWidth + delta),
);
if (target === "panel") {
if (next === sizeRef.current.panelWidth) return;
sizeRef.current.panelWidth = next;
applyPanelWidth(next);
} else {
if (next === sizeRef.current.gitPanelWidth) return;
sizeRef.current.gitPanelWidth = next;
applyGitPanelWidth(next);
}
} else if (target === "panel-bottom") {
const delta = startY - y;
const next = Math.min(
Expand All @@ -262,15 +304,6 @@ export function useResizablePanels(refs: {
if (next === sizeRef.current.panelHeight) return;
sizeRef.current.panelHeight = next;
applyPanelHeight(next);
} else if (target === "git-panel") {
const delta = startX - x;
const next = Math.min(
dynamicMaxGitPanel,
Math.max(GIT_PANEL_MIN_WIDTH, startWidth + delta),
);
if (next === sizeRef.current.gitPanelWidth) return;
sizeRef.current.gitPanelWidth = next;
applyGitPanelWidth(next);
}
}

Expand Down Expand Up @@ -329,6 +362,7 @@ export function useResizablePanels(refs: {
// immediately (there's no drag "end" event to persist on).
const nudgeResize = useCallback(
(target: ResizeTarget, deltaPx: number) => {
const limits = getResizeLimitsRef.current?.(target) ?? null;
if (target === "sidebar") {
const next = Math.min(
SIDEBAR_MAX_WIDTH,
Expand All @@ -338,10 +372,8 @@ export function useResizablePanels(refs: {
applySidebarWidth(next);
setSidebarWidth(next);
} else if (target === "panel") {
const next = Math.min(
PANEL_MAX_WIDTH,
Math.max(PANEL_MIN_WIDTH, sizeRef.current.panelWidth + deltaPx),
);
const range = resolveRange(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH, limits);
const next = Math.min(range.max, Math.max(range.min, sizeRef.current.panelWidth + deltaPx));
sizeRef.current.panelWidth = next;
applyPanelWidth(next);
setPanelWidth(next);
Expand All @@ -354,9 +386,10 @@ export function useResizablePanels(refs: {
applyPanelHeight(next);
setPanelHeight(next);
} else {
const range = resolveRange(GIT_PANEL_MIN_WIDTH, GIT_PANEL_MAX_WIDTH, limits);
const next = Math.min(
GIT_PANEL_MAX_WIDTH,
Math.max(GIT_PANEL_MIN_WIDTH, sizeRef.current.gitPanelWidth + deltaPx),
range.max,
Math.max(range.min, sizeRef.current.gitPanelWidth + deltaPx),
);
sizeRef.current.gitPanelWidth = next;
applyGitPanelWidth(next);
Expand Down