Shared sidebar layout

This commit is contained in:
Gregory Schier
2026-03-12 14:19:29 -07:00
parent 87e60372fe
commit 47f0daabff
6 changed files with 200 additions and 141 deletions

View File

@@ -3,8 +3,7 @@ import { settingsAtom, workspacesAtom } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import * as m from 'motion/react-m';
import type { CSSProperties } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useMemo } from 'react';
import {
useEnsureActiveCookieJar,
useSubscribeActiveCookieJarId,
@@ -40,19 +39,16 @@ import { HStack } from './core/Stacks';
import { ErrorBoundary } from './ErrorBoundary';
import { FolderLayout } from './FolderLayout';
import { GrpcConnectionLayout } from './GrpcConnectionLayout';
import { HeaderSize } from '@yaakapp-internal/ui';
import { HeaderSize, SidebarLayout } from '@yaakapp-internal/ui';
import { HttpRequestLayout } from './HttpRequestLayout';
import { Overlay } from './Overlay';
import { ResizeHandle, type ResizeHandleEvent } from '@yaakapp-internal/ui';
import Sidebar from './Sidebar';
import { SidebarActions } from './SidebarActions';
import { WebsocketRequestLayout } from './WebsocketRequestLayout';
import { WorkspaceHeader } from './WorkspaceHeader';
const side = { gridArea: 'side' };
const head = { gridArea: 'head' };
const body = { gridArea: 'body' };
const drag = { gridArea: 'drag' };
export function Workspace() {
// First, subscribe to some things applicable to workspaces
@@ -66,50 +62,6 @@ export function Workspace() {
const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden();
const activeEnvironment = useAtomValue(activeEnvironmentAtom);
const floating = useShouldFloatSidebar();
const [isResizing, setIsResizing] = useState<boolean>(false);
const startWidth = useRef<number | null>(null);
const handleResizeMove = useCallback(
async ({ x, xStart }: ResizeHandleEvent) => {
if (width == null || startWidth.current == null) return;
const newWidth = startWidth.current + (x - xStart);
if (newWidth < 50) {
if (!sidebarHidden) await setSidebarHidden(true);
resetWidth();
} else {
if (sidebarHidden) await setSidebarHidden(false);
setWidth(newWidth);
}
},
[width, sidebarHidden, setSidebarHidden, resetWidth, setWidth],
);
const handleResizeStart = useCallback(() => {
startWidth.current = width ?? null;
setIsResizing(true);
}, [width]);
const handleResizeEnd = useCallback(() => {
setIsResizing(false);
startWidth.current = null;
}, []);
const sideWidth = sidebarHidden ? 0 : width;
const styles = useMemo<CSSProperties>(
() => ({
gridTemplate: floating
? `
' ${head.gridArea}' auto
' ${body.gridArea}' minmax(0,1fr)
/ 1fr`
: `
' ${head.gridArea} ${head.gridArea} ${head.gridArea}' auto
' ${side.gridArea} ${drag.gridArea} ${body.gridArea}' minmax(0,1fr)
/ ${sideWidth}px 0 1fr`,
}),
[sideWidth, floating],
);
const environmentBgStyle = useMemo(() => {
if (activeEnvironment?.color == null) return undefined;
@@ -122,86 +74,87 @@ export function Workspace() {
return null;
}
return (
<div
style={styles}
className={classNames(
'grid w-full h-full',
// Animate sidebar width changes but only when not resizing
// because it's too slow to animate on mouse move
!isResizing && 'transition-grid',
)}
const header = (
<HeaderSize
data-tauri-drag-region
size="lg"
className="relative x-theme-appHeader bg-surface"
osType={osType}
hideWindowControls={settings.hideWindowControls}
useNativeTitlebar={settings.useNativeTitlebar}
interfaceScale={settings.interfaceScale}
>
{floating ? (
<Overlay
open={!floatingSidebarHidden}
portalName="sidebar"
onClose={() => setFloatingSidebarHidden(true)}
zIndex={20}
>
<m.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
className={classNames(
'x-theme-sidebar',
'absolute top-0 left-0 bottom-0 bg-surface border-r border-border-subtle w-[20rem]',
'grid grid-rows-[auto_1fr]',
)}
>
<HeaderSize hideControls size="lg" className="border-transparent flex items-center" osType={osType} hideWindowControls={settings.hideWindowControls} useNativeTitlebar={settings.useNativeTitlebar} interfaceScale={settings.interfaceScale}>
<SidebarActions />
</HeaderSize>
<ErrorBoundary name="Sidebar (Floating)">
<Sidebar />
</ErrorBoundary>
</m.div>
</Overlay>
) : (
<>
<div style={side} className={classNames('x-theme-sidebar', 'overflow-hidden bg-surface')}>
<ErrorBoundary name="Sidebar">
<Sidebar className="border-r border-border-subtle" />
</ErrorBoundary>
</div>
<ResizeHandle
style={drag}
className="-translate-x-[1px]"
justify="end"
side="right"
onResizeStart={handleResizeStart}
onResizeEnd={handleResizeEnd}
onResizeMove={handleResizeMove}
onReset={resetWidth}
/>
</>
)}
<HeaderSize
data-tauri-drag-region
size="lg"
className="relative x-theme-appHeader bg-surface"
style={head}
osType={osType}
hideWindowControls={settings.hideWindowControls}
useNativeTitlebar={settings.useNativeTitlebar}
interfaceScale={settings.interfaceScale}
>
<div className="absolute inset-0 pointer-events-none">
<div // Add subtle background
style={environmentBgStyle}
className="absolute inset-0 opacity-[0.07]"
/>
<div // Add a subtle border bottom
style={environmentBgStyle}
className="absolute left-0 right-0 -bottom-[1px] h-[1px] opacity-20"
/>
</div>
<WorkspaceHeader className="pointer-events-none" />
</HeaderSize>
<ErrorBoundary name="Workspace Body">
<WorkspaceBody />
<div className="absolute inset-0 pointer-events-none">
<div
style={environmentBgStyle}
className="absolute inset-0 opacity-[0.07]"
/>
<div
style={environmentBgStyle}
className="absolute left-0 right-0 -bottom-[1px] h-[1px] opacity-20"
/>
</div>
<WorkspaceHeader className="pointer-events-none" />
</HeaderSize>
);
const workspaceBody = (
<ErrorBoundary name="Workspace Body">
<WorkspaceBody />
</ErrorBoundary>
);
const sidebarContent = (
<div className="x-theme-sidebar overflow-hidden bg-surface h-full">
<ErrorBoundary name="Sidebar">
<Sidebar className="border-r border-border-subtle" />
</ErrorBoundary>
</div>
);
return (
<div className="grid w-full h-full grid-rows-[auto_1fr]">
{header}
{floating ? (
<>
<Overlay
open={!floatingSidebarHidden}
portalName="sidebar"
onClose={() => setFloatingSidebarHidden(true)}
zIndex={20}
>
<m.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
className={classNames(
'x-theme-sidebar',
'absolute top-0 left-0 bottom-0 bg-surface border-r border-border-subtle w-[20rem]',
'grid grid-rows-[auto_1fr]',
)}
>
<HeaderSize hideControls size="lg" className="border-transparent flex items-center" osType={osType} hideWindowControls={settings.hideWindowControls} useNativeTitlebar={settings.useNativeTitlebar} interfaceScale={settings.interfaceScale}>
<SidebarActions />
</HeaderSize>
<ErrorBoundary name="Sidebar (Floating)">
<Sidebar />
</ErrorBoundary>
</m.div>
</Overlay>
{workspaceBody}
</>
) : (
<SidebarLayout
width={width ?? 250}
onWidthChange={setWidth}
hidden={sidebarHidden ?? false}
onHiddenChange={(hidden) => setSidebarHidden(hidden)}
sidebar={sidebarContent}
>
{workspaceBody}
</SidebarLayout>
)}
</div>
);
}
function WorkspaceBody() {

View File

@@ -13,15 +13,16 @@ import classNames from 'classnames';
interface Props {
exchanges: HttpExchange[];
style?: React.CSSProperties;
className?: string;
}
export function ExchangesTable({ exchanges, style }: Props) {
export function ExchangesTable({ exchanges, style, className }: Props) {
if (exchanges.length === 0) {
return <p className="text-text-subtlest text-sm">No traffic yet</p>;
}
return (
<Table scrollable className="px-2" style={style}>
<Table scrollable className={classNames('px-2', className)} style={style}>
<TableHead>
<TableRow>
<TableHeaderCell>Method</TableHeaderCell>

View File

@@ -1,6 +1,7 @@
import { HeaderSize, SplitLayout } from '@yaakapp-internal/ui';
import { HeaderSize, SidebarLayout } from '@yaakapp-internal/ui';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import { useLocalStorage } from 'react-use';
import { useRpcQueryWithEvent } from '../hooks/useRpcQueryWithEvent';
import { getOsType } from '../lib/tauri';
import { ActionIconButton } from './ActionIconButton';
@@ -10,6 +11,7 @@ import { filteredExchangesAtom, Sidebar } from './Sidebar';
export function ProxyLayout() {
const os = getOsType();
const exchanges = useAtomValue(filteredExchangesAtom);
const [sidebarWidth, setSidebarWidth] = useLocalStorage('sidebar_width', 250);
const { data: proxyState } = useRpcQueryWithEvent('get_proxy_state', {}, 'proxy_state_changed');
const isRunning = proxyState?.state === 'running';
@@ -56,13 +58,13 @@ export function ProxyLayout() {
</div>
</div>
</HeaderSize>
<SplitLayout
storageKey="proxy_sidebar"
layout="horizontal"
defaultRatio={0.8}
firstSlot={({ style }) => <Sidebar style={style} />}
secondSlot={({ style }) => <ExchangesTable style={style} exchanges={exchanges} />}
/>
<SidebarLayout
width={sidebarWidth ?? 250}
onWidthChange={setSidebarWidth}
sidebar={<Sidebar />}
>
<ExchangesTable exchanges={exchanges} className="overflow-auto h-full" />
</SidebarLayout>
</div>
);
}

View File

@@ -190,17 +190,14 @@ function ItemInner({ item }: { item: SidebarItem }) {
);
}
export function Sidebar({ style }: { style?: React.CSSProperties }) {
export function Sidebar() {
const tree = useAtomValue(sidebarTreeAtom);
const treeId = SIDEBAR_TREE_ID;
const getItemKey = useCallback((item: SidebarItem) => item.id, []);
return (
<aside
style={style}
className="x-theme-sidebar bg-surface h-full w-full min-w-0 overflow-y-auto border-r border-border-subtle"
>
<aside className="x-theme-sidebar bg-surface h-full w-full min-w-0 overflow-y-auto border-r border-border-subtle">
<div className="pt-2 text-xs">
<Tree
treeId={treeId}

View File

@@ -0,0 +1,105 @@
import classNames from 'classnames';
import type { CSSProperties, ReactNode } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import type { ResizeHandleEvent } from './ResizeHandle';
import { ResizeHandle } from './ResizeHandle';
const side = { gridArea: 'side', minWidth: 0 };
const drag = { gridArea: 'drag' };
const body = { gridArea: 'body', minWidth: 0 };
interface Props {
width: number;
onWidthChange: (width: number) => void;
hidden?: boolean;
onHiddenChange?: (hidden: boolean) => void;
defaultWidth?: number;
minWidth?: number;
className?: string;
sidebar: ReactNode;
children: ReactNode;
}
export function SidebarLayout({
width,
onWidthChange,
hidden = false,
onHiddenChange,
defaultWidth = 250,
minWidth = 50,
className,
sidebar,
children,
}: Props) {
const [isResizing, setIsResizing] = useState(false);
const startWidth = useRef<number | null>(null);
const sideWidth = hidden ? 0 : width;
const styles = useMemo<CSSProperties>(
() => ({
gridTemplate: `
' ${side.gridArea} ${drag.gridArea} ${body.gridArea}' minmax(0,1fr)
/ ${sideWidth}px 0 1fr`,
}),
[sideWidth],
);
const handleResizeStart = useCallback(() => {
startWidth.current = width;
setIsResizing(true);
}, [width]);
const handleResizeEnd = useCallback(() => {
setIsResizing(false);
startWidth.current = null;
}, []);
const handleResizeMove = useCallback(
({ x, xStart }: ResizeHandleEvent) => {
if (startWidth.current == null) return;
const newWidth = startWidth.current + (x - xStart);
if (newWidth < minWidth) {
onHiddenChange?.(true);
onWidthChange(defaultWidth);
} else {
if (hidden) onHiddenChange?.(false);
onWidthChange(newWidth);
}
},
[minWidth, hidden, onHiddenChange, onWidthChange, defaultWidth],
);
const handleReset = useCallback(() => {
onWidthChange(defaultWidth);
}, [onWidthChange, defaultWidth]);
return (
<div
style={styles}
className={classNames(
className,
'grid w-full h-full',
!isResizing && 'transition-grid',
)}
>
<div style={side} className="overflow-hidden">
{sidebar}
</div>
<ResizeHandle
style={drag}
className="-translate-x-[1px]"
justify="end"
side="right"
onResizeStart={handleResizeStart}
onResizeEnd={handleResizeEnd}
onResizeMove={handleResizeMove}
onReset={handleReset}
/>
<div style={body} className="min-w-0">
{children}
</div>
</div>
);
}

View File

@@ -21,6 +21,7 @@ export type { TreeItemProps } from "./components/tree/TreeItem";
export { isSelectedFamily, selectedIdsFamily } from "./components/tree/atoms";
export { minPromiseMillis } from "./lib/minPromiseMillis";
export { ResizeHandle } from "./components/ResizeHandle";
export { SidebarLayout } from "./components/SidebarLayout";
export type { ResizeHandleEvent } from "./components/ResizeHandle";
export { SplitLayout } from "./components/SplitLayout";
export type { SplitLayoutLayout, SlotProps } from "./components/SplitLayout";