Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add resizable and collapsible panel layout #5926

Merged
merged 12 commits into from
Dec 30, 2024
191 changes: 191 additions & 0 deletions frontend/src/components/layout/resizable-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import React, { CSSProperties, JSX, useEffect, useRef, useState } from "react";
import {
VscChevronDown,
VscChevronLeft,
VscChevronRight,
VscChevronUp,
} from "react-icons/vsc";
import { twMerge } from "tailwind-merge";
import { IconButton } from "../shared/buttons/icon-button";

export enum Orientation {
HORIZONTAL = "horizontal",
VERTICAL = "vertical",
}

enum Collapse {
COLLAPSED = "collapsed",
SPLIT = "split",
FILLED = "filled",
}

type ResizablePanelProps = {
firstChild: React.ReactNode;
firstClassName: string | undefined;
secondChild: React.ReactNode;
secondClassName: string | undefined;
className: string | undefined;
orientation: Orientation;
initialSize: number;
};

export function ResizablePanel({
firstChild,
firstClassName,
secondChild,
secondClassName,
className,
orientation,
initialSize,
}: ResizablePanelProps): JSX.Element {
const [firstSize, setFirstSize] = useState<number>(initialSize);
const [dividerPosition, setDividerPosition] = useState<number | null>(null);
const firstRef = useRef<HTMLDivElement>(null);
const secondRef = useRef<HTMLDivElement>(null);
const [collapse, setCollapse] = useState<Collapse>(Collapse.SPLIT);
const isHorizontal = orientation === Orientation.HORIZONTAL;

useEffect(() => {
if (dividerPosition == null || !firstRef.current) {
return undefined;
}
const getFirstSizeFromEvent = (e: MouseEvent) => {
const position = isHorizontal ? e.clientX : e.clientY;
return firstSize + position - dividerPosition;
};
const onMouseMove = (e: MouseEvent) => {
e.preventDefault();
const newFirstSize = `${getFirstSizeFromEvent(e)}px`;
const { current } = firstRef;
if (current) {
if (isHorizontal) {
current.style.width = newFirstSize;
current.style.minWidth = newFirstSize;
} else {
current.style.height = newFirstSize;
current.style.minHeight = newFirstSize;
}
}
};
const onMouseUp = (e: MouseEvent) => {
e.preventDefault();
if (firstRef.current) {
firstRef.current.style.transition = "";
}
if (secondRef.current) {
secondRef.current.style.transition = "";
}
setFirstSize(getFirstSizeFromEvent(e));
setDividerPosition(null);
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
return () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
}, [dividerPosition, firstSize, orientation]);

const onMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
if (firstRef.current) {
firstRef.current.style.transition = "none";
}
if (secondRef.current) {
secondRef.current.style.transition = "none";
}
const position = isHorizontal ? e.clientX : e.clientY;
setDividerPosition(position);
};

const getStyleForFirst = () => {
const style: CSSProperties = { overflow: "hidden" };
if (collapse === Collapse.COLLAPSED) {
style.opacity = 0;
style.width = 0;
style.minWidth = 0;
style.height = 0;
style.minHeight = 0;
} else if (collapse === Collapse.SPLIT) {
const firstSizePx = `${firstSize}px`;
if (isHorizontal) {
style.width = firstSizePx;
style.minWidth = firstSizePx;
} else {
style.height = firstSizePx;
style.minHeight = firstSizePx;
}
} else {
style.flexGrow = 1;
}
return style;
};

const getStyleForSecond = () => {
const style: CSSProperties = { overflow: "hidden" };
if (collapse === Collapse.FILLED) {
style.opacity = 0;
style.width = 0;
style.minWidth = 0;
style.height = 0;
style.minHeight = 0;
} else if (collapse === Collapse.SPLIT) {
style.flexGrow = 1;
} else {
style.flexGrow = 1;
}
return style;
};

const onCollapse = () => {
if (collapse === Collapse.SPLIT) {
setCollapse(Collapse.COLLAPSED);
} else {
setCollapse(Collapse.SPLIT);
}
};

const onExpand = () => {
if (collapse === Collapse.SPLIT) {
setCollapse(Collapse.FILLED);
} else {
setCollapse(Collapse.SPLIT);
}
};

return (
<div className={twMerge("flex", !isHorizontal && "flex-col", className)}>
<div
ref={firstRef}
className={twMerge(firstClassName, "transition-all ease-soft-spring")}
style={getStyleForFirst()}
>
{firstChild}
</div>
<div
className={`${isHorizontal ? "cursor-ew-resize w-3 flex-col" : "cursor-ns-resize h-3 flex-row"} shrink-0 flex justify-center items-center`}
onMouseDown={collapse === Collapse.SPLIT ? onMouseDown : undefined}
>
<IconButton
icon={isHorizontal ? <VscChevronLeft /> : <VscChevronUp />}
ariaLabel="Collapse"
onClick={onCollapse}
/>
<IconButton
icon={isHorizontal ? <VscChevronRight /> : <VscChevronDown />}
ariaLabel="Expand"
onClick={onExpand}
/>
</div>
<div
ref={secondRef}
className={twMerge(secondClassName, "transition-all ease-soft-spring")}
style={getStyleForSecond()}
>
{secondChild}
</div>
</div>
);
}
74 changes: 58 additions & 16 deletions frontend/src/routes/_oh.app/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import { useAuth } from "#/context/auth-context";
import { useSettings } from "#/context/settings-context";
import { useConversationConfig } from "#/hooks/query/use-conversation-config";
import { Container } from "#/components/layout/container";
import {
Orientation,
ResizablePanel,
} from "#/components/layout/resizable-panel";
import Security from "#/components/shared/modals/security/security";
import { useEndSession } from "#/hooks/use-end-session";
import { useUserConversation } from "#/hooks/query/get-conversation-permissions";
Expand All @@ -35,6 +39,7 @@ import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
function AppContent() {
const { gitHubToken } = useAuth();
const endSession = useEndSession();
const [width, setWidth] = React.useState(window.innerWidth);

const { settings } = useSettings();
const { conversationId } = useConversation();
Expand Down Expand Up @@ -87,24 +92,49 @@ function AppContent() {
dispatch(clearJupyter());
});

function handleResize() {
setWidth(window.innerWidth);
}

React.useEffect(() => {
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);

const {
isOpen: securityModalIsOpen,
onOpen: onSecurityModalOpen,
onOpenChange: onSecurityModalOpenChange,
} = useDisclosure();

return (
<WsClientProvider ghToken={gitHubToken} conversationId={conversationId}>
<EventHandler>
<div data-testid="app-route" className="flex flex-col h-full gap-3">
<div className="flex h-full overflow-auto gap-3">
<Container className="w-full md:w-[390px] max-h-full relative">
<ChatInterface />
</Container>

<div className="hidden md:flex flex-col grow gap-3">
function renderMain() {
if (width <= 640) {
return (
<div className="rounded-xl overflow-hidden border border-neutral-600 w-full">
<ChatInterface />
</div>
);
}
return (
<ResizablePanel
orientation={Orientation.HORIZONTAL}
className="grow h-full min-h-0 min-w-0"
initialSize={500}
firstClassName="rounded-xl overflow-hidden border border-neutral-600"
secondClassName="flex flex-col overflow-hidden"
firstChild={<ChatInterface />}
secondChild={
<ResizablePanel
orientation={Orientation.VERTICAL}
className="grow h-full min-h-0 min-w-0"
initialSize={500}
firstClassName="rounded-xl overflow-hidden border border-neutral-600"
secondClassName="flex flex-col overflow-hidden"
firstChild={
<Container
className="h-2/3"
className="h-full"
labels={[
{ label: "Workspace", to: "", icon: <CodeIcon /> },
{ label: "Jupyter", to: "jupyter", icon: <ListIcon /> },
Expand All @@ -124,18 +154,30 @@ function AppContent() {
<Outlet />
</FilesProvider>
</Container>
{/* Terminal uses some API that is not compatible in a server-environment. For this reason, we lazy load it to ensure
* that it loads only in the client-side. */}
}
secondChild={
<Container
className="h-1/3 overflow-scroll"
className="h-full overflow-scroll"
label={<TerminalStatusLabel />}
>
{/* Terminal uses some API that is not compatible in a server-environment. For this reason, we lazy load it to ensure
* that it loads only in the client-side. */}
<React.Suspense fallback={<div className="h-full" />}>
<Terminal secrets={secrets} />
</React.Suspense>
</Container>
</div>
</div>
}
/>
}
/>
);
}

return (
<WsClientProvider ghToken={gitHubToken} conversationId={conversationId}>
<EventHandler>
<div data-testid="app-route" className="flex flex-col h-full gap-3">
<div className="flex h-full overflow-auto">{renderMain()}</div>

<div className="h-[60px]">
<Controls
Expand Down
Loading