mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-12 02:26:30 -05:00
Refactor and improve layout resizing
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Yaak App</title>
|
||||
<!-- <script src="http://localhost:8097"></script>-->
|
||||
|
||||
@@ -12,7 +12,8 @@ import { keyValueQueryKey } from '../hooks/useKeyValue';
|
||||
import { requestsQueryKey } from '../hooks/useRequests';
|
||||
import { responsesQueryKey } from '../hooks/useResponses';
|
||||
import { routePaths } from '../hooks/useRoutes';
|
||||
import { SidebarDisplayKeys } from '../hooks/useSidebarDisplay';
|
||||
import type { SidebarDisplay } from '../hooks/useSidebarDisplay';
|
||||
import { sidebarDisplayDefaultValue, sidebarDisplayKey } from '../hooks/useSidebarDisplay';
|
||||
import { workspacesQueryKey } from '../hooks/useWorkspaces';
|
||||
import { DEFAULT_FONT_SIZE } from '../lib/constants';
|
||||
import { extractKeyValue, getKeyValue, setKeyValue } from '../lib/keyValueStore';
|
||||
@@ -139,8 +140,14 @@ await listen('refresh', () => {
|
||||
});
|
||||
|
||||
await listen('toggle_sidebar', async () => {
|
||||
const hidden = await getKeyValue<boolean>({ key: SidebarDisplayKeys.hidden, fallback: false });
|
||||
await setKeyValue({ key: SidebarDisplayKeys.hidden, value: !hidden });
|
||||
const display = await getKeyValue<SidebarDisplay>({
|
||||
key: sidebarDisplayKey,
|
||||
fallback: sidebarDisplayDefaultValue,
|
||||
});
|
||||
await setKeyValue({
|
||||
key: sidebarDisplayKey,
|
||||
value: { width: display.width, hidden: !display.hidden },
|
||||
});
|
||||
});
|
||||
|
||||
await listen('zoom', ({ payload: zoomDelta }: { payload: number }) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import classnames from 'classnames';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { useKeyValue } from '../hooks/useKeyValue';
|
||||
import { useUpdateRequest } from '../hooks/useUpdateRequest';
|
||||
@@ -15,11 +16,12 @@ import { ParametersEditor } from './ParameterEditor';
|
||||
import { UrlBar } from './UrlBar';
|
||||
|
||||
interface Props {
|
||||
style?: CSSProperties;
|
||||
fullHeight: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RequestPane({ fullHeight, className }: Props) {
|
||||
export const RequestPane = memo(function RequestPane({ style, fullHeight, className }: Props) {
|
||||
const activeRequest = useActiveRequest();
|
||||
const activeRequestId = activeRequest?.id ?? null;
|
||||
const updateRequest = useUpdateRequest(activeRequestId);
|
||||
@@ -78,6 +80,7 @@ export function RequestPane({ fullHeight, className }: Props) {
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={classnames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}
|
||||
>
|
||||
{activeRequest && (
|
||||
@@ -146,4 +149,4 @@ export function RequestPane({ fullHeight, className }: Props) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import classnames from 'classnames';
|
||||
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import React, { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useWindowSize } from 'react-use';
|
||||
import { useKeyValue } from '../hooks/useKeyValue';
|
||||
import { useSidebarDisplay } from '../hooks/useSidebarDisplay';
|
||||
import { clamp } from '../lib/clamp';
|
||||
import { RequestPane } from './RequestPane';
|
||||
import { ResizeHandle } from './ResizeHandle';
|
||||
import { ResponsePane } from './ResponsePane';
|
||||
import { ResizeBar } from './Workspace';
|
||||
|
||||
interface Props {
|
||||
style: CSSProperties;
|
||||
vertical?: boolean;
|
||||
}
|
||||
|
||||
const rqst = { gridArea: 'rqst' };
|
||||
@@ -19,9 +20,11 @@ const drag = { gridArea: 'drag' };
|
||||
const DEFAULT = 0.5;
|
||||
const MIN_WIDTH_PX = 10;
|
||||
const MIN_HEIGHT_PX = 100;
|
||||
const STACK_VERTICAL_WIDTH = 600;
|
||||
|
||||
export default function RequestResponse({ style, vertical }: Props) {
|
||||
export const RequestResponse = memo(function RequestResponse({ style }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [vertical, setVertical] = useState<boolean>(false);
|
||||
const widthKv = useKeyValue<number>({ key: 'body_width', defaultValue: DEFAULT });
|
||||
const heightKv = useKeyValue<number>({ key: 'body_height', defaultValue: DEFAULT });
|
||||
const width = widthKv.value ?? DEFAULT;
|
||||
@@ -31,19 +34,27 @@ export default function RequestResponse({ style, vertical }: Props) {
|
||||
null,
|
||||
);
|
||||
|
||||
const windowSize = useWindowSize();
|
||||
const sidebar = useSidebarDisplay();
|
||||
useLayoutEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const { width } = containerRef.current.getBoundingClientRect();
|
||||
setVertical(width < STACK_VERTICAL_WIDTH);
|
||||
}, [containerRef.current, windowSize, sidebar.width, sidebar.hidden]);
|
||||
|
||||
const styles = useMemo<CSSProperties>(
|
||||
() => ({
|
||||
...style,
|
||||
gridTemplate: vertical
|
||||
? `
|
||||
' ${rqst.gridArea}' minmax(0,${1 - height}fr)
|
||||
' ${drag.gridArea}' auto
|
||||
' ${drag.gridArea}' 0
|
||||
' ${resp.gridArea}' minmax(0,${height}fr)
|
||||
/ 1fr
|
||||
`
|
||||
: `
|
||||
' ${rqst.gridArea} ${drag.gridArea} ${resp.gridArea}' minmax(0,1fr)
|
||||
/ ${1 - width}fr auto ${width}fr
|
||||
/ ${1 - width}fr 0 ${width}fr
|
||||
`,
|
||||
}),
|
||||
[vertical, width, height, style],
|
||||
@@ -108,22 +119,18 @@ export default function RequestResponse({ style, vertical }: Props) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="grid w-full h-full p-3" style={styles}>
|
||||
<div style={rqst}>
|
||||
<RequestPane fullHeight={!vertical} />
|
||||
</div>
|
||||
<div style={drag} className={classnames('relative flex-grow-0', vertical ? 'h-3' : 'w-3')}>
|
||||
<ResizeBar
|
||||
isResizing={isResizing}
|
||||
onResizeStart={handleResizeStart}
|
||||
onReset={handleReset}
|
||||
side={vertical ? 'top' : 'left'}
|
||||
justify="center"
|
||||
/>
|
||||
</div>
|
||||
<div style={resp}>
|
||||
<ResponsePane />
|
||||
</div>
|
||||
<div ref={containerRef} className="grid gap-1.5 w-full h-full p-3" style={styles}>
|
||||
<RequestPane style={rqst} fullHeight={!vertical} />
|
||||
<ResizeHandle
|
||||
style={drag}
|
||||
isResizing={isResizing}
|
||||
className={classnames(vertical ? 'translate-y-0.5' : 'translate-x-0.5')}
|
||||
onResizeStart={handleResizeStart}
|
||||
onReset={handleReset}
|
||||
side={vertical ? 'top' : 'left'}
|
||||
justify="center"
|
||||
/>
|
||||
<ResponsePane style={resp} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
57
src-web/components/ResizeHandle.tsx
Normal file
57
src-web/components/ResizeHandle.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import classnames from 'classnames';
|
||||
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
interface ResizeBarProps {
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
barClassName?: string;
|
||||
isResizing: boolean;
|
||||
onResizeStart: (e: ReactMouseEvent<HTMLDivElement>) => void;
|
||||
onReset?: () => void;
|
||||
side: 'left' | 'right' | 'top';
|
||||
justify: 'center' | 'end' | 'start';
|
||||
}
|
||||
|
||||
export function ResizeHandle({
|
||||
style,
|
||||
justify,
|
||||
className,
|
||||
onResizeStart,
|
||||
onReset,
|
||||
isResizing,
|
||||
side,
|
||||
}: ResizeBarProps) {
|
||||
const vertical = side === 'top';
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
draggable
|
||||
style={style}
|
||||
className={classnames(
|
||||
className,
|
||||
'group z-10 flex cursor-ew-resize',
|
||||
vertical ? 'w-full h-3 cursor-ns-resize' : 'h-full w-3 cursor-ew-resize',
|
||||
justify === 'center' && 'justify-center',
|
||||
justify === 'end' && 'justify-end',
|
||||
justify === 'start' && 'justify-start',
|
||||
side === 'right' && 'right-0',
|
||||
side === 'left' && 'left-0',
|
||||
side === 'top' && 'top-0',
|
||||
)}
|
||||
onDragStart={onResizeStart}
|
||||
onDoubleClick={onReset}
|
||||
>
|
||||
{/* Show global overlay with cursor style to ensure cursor remains the same when moving quickly */}
|
||||
{isResizing && (
|
||||
<div
|
||||
className={classnames(
|
||||
'fixed -left-20 -right-20 -top-20 -bottom-20 cursor-ew-resize',
|
||||
vertical && 'cursor-ns-resize',
|
||||
!vertical && 'cursor-ew-resize',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import classnames from 'classnames';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { useActiveRequestId } from '../hooks/useActiveRequestId';
|
||||
import { useDeleteResponse } from '../hooks/useDeleteResponse';
|
||||
@@ -17,10 +18,11 @@ import { StatusColor } from './core/StatusColor';
|
||||
import { Webview } from './core/Webview';
|
||||
|
||||
interface Props {
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ResponsePane = memo(function ResponsePane({ className }: Props) {
|
||||
export const ResponsePane = memo(function ResponsePane({ style, className }: Props) {
|
||||
const [pinnedResponseId, setPinnedResponseId] = useState<string | null>(null);
|
||||
const activeRequestId = useActiveRequestId();
|
||||
const responses = useResponses(activeRequestId);
|
||||
@@ -43,95 +45,93 @@ export const ResponsePane = memo(function ResponsePane({ className }: Props) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classnames(className, 'h-full w-full')}>
|
||||
<div
|
||||
className={classnames(
|
||||
'bg-gray-50 max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 ',
|
||||
'dark:bg-gray-100 rounded-md overflow-hidden border border-highlight',
|
||||
'shadow shadow-gray-100 dark:shadow-gray-0',
|
||||
)}
|
||||
<div
|
||||
style={style}
|
||||
className={classnames(
|
||||
className,
|
||||
'bg-gray-50 max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 ',
|
||||
'dark:bg-gray-100 rounded-md overflow-hidden border border-highlight',
|
||||
'shadow shadow-gray-100 dark:shadow-gray-0 relative',
|
||||
)}
|
||||
>
|
||||
<HStack
|
||||
alignItems="center"
|
||||
className="italic text-gray-700 text-sm w-full mb-1 flex-shrink-0 pl-2"
|
||||
>
|
||||
{/*<HStack as={WindowDragRegion} items="center" className="pl-1.5 pr-1">*/}
|
||||
{/*</HStack>*/}
|
||||
<HStack
|
||||
alignItems="center"
|
||||
className="italic text-gray-700 text-sm w-full mb-1 flex-shrink-0 pl-2"
|
||||
>
|
||||
{activeResponse && (
|
||||
<>
|
||||
<div className="whitespace-nowrap">
|
||||
<StatusColor statusCode={activeResponse.status}>
|
||||
{activeResponse.status}
|
||||
{activeResponse.statusReason && ` ${activeResponse.statusReason}`}
|
||||
</StatusColor>
|
||||
•
|
||||
{activeResponse.elapsed}ms •
|
||||
{Math.round(activeResponse.body.length / 1000)} KB
|
||||
</div>
|
||||
{activeResponse && (
|
||||
<>
|
||||
<div className="whitespace-nowrap">
|
||||
<StatusColor statusCode={activeResponse.status}>
|
||||
{activeResponse.status}
|
||||
{activeResponse.statusReason && ` ${activeResponse.statusReason}`}
|
||||
</StatusColor>
|
||||
•
|
||||
{activeResponse.elapsed}ms •
|
||||
{Math.round(activeResponse.body.length / 1000)} KB
|
||||
</div>
|
||||
|
||||
<HStack alignItems="center" className="ml-auto h-8">
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: viewMode === 'pretty' ? 'View Raw' : 'View Prettified',
|
||||
onSelect: toggleViewMode,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Clear Response',
|
||||
onSelect: deleteResponse.mutate,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{
|
||||
label: `Clear ${responses.length} ${pluralize('Response', responses.length)}`,
|
||||
onSelect: deleteAllResponses.mutate,
|
||||
hidden: responses.length <= 1,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
...responses.slice(0, 10).map((r) => ({
|
||||
label: r.status + ' - ' + r.elapsed + ' ms',
|
||||
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <></>,
|
||||
onSelect: () => setPinnedResponseId(r.id),
|
||||
})),
|
||||
]}
|
||||
>
|
||||
<IconButton
|
||||
title="Show response history"
|
||||
icon="triangleDown"
|
||||
className="ml-auto"
|
||||
size="sm"
|
||||
/>
|
||||
</Dropdown>
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack alignItems="center" className="ml-auto h-8">
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: viewMode === 'pretty' ? 'View Raw' : 'View Prettified',
|
||||
onSelect: toggleViewMode,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Clear Response',
|
||||
onSelect: deleteResponse.mutate,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{
|
||||
label: `Clear ${responses.length} ${pluralize('Response', responses.length)}`,
|
||||
onSelect: deleteAllResponses.mutate,
|
||||
hidden: responses.length <= 1,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
...responses.slice(0, 10).map((r) => ({
|
||||
label: r.status + ' - ' + r.elapsed + ' ms',
|
||||
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <></>,
|
||||
onSelect: () => setPinnedResponseId(r.id),
|
||||
})),
|
||||
]}
|
||||
>
|
||||
<IconButton
|
||||
title="Show response history"
|
||||
icon="triangleDown"
|
||||
className="ml-auto"
|
||||
size="sm"
|
||||
/>
|
||||
</Dropdown>
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{!activeResponse ? null : activeResponse?.error ? (
|
||||
<div className="p-1">
|
||||
<div className="text-white bg-red-500 px-3 py-3 rounded">{activeResponse.error}</div>
|
||||
</div>
|
||||
) : viewMode === 'pretty' && contentType.includes('html') ? (
|
||||
<Webview body={activeResponse.body} contentType={contentType} url={activeResponse.url} />
|
||||
) : viewMode === 'pretty' && contentType.includes('json') ? (
|
||||
<Editor
|
||||
readOnly
|
||||
key={`${contentType}:${activeResponse.updatedAt}:pretty`}
|
||||
className="bg-gray-50 dark:!bg-gray-100"
|
||||
defaultValue={tryFormatJson(activeResponse?.body)}
|
||||
contentType={contentType}
|
||||
/>
|
||||
) : activeResponse?.body ? (
|
||||
<Editor
|
||||
readOnly
|
||||
key={`${contentType}:${activeResponse.updatedAt}`}
|
||||
className="bg-gray-50 dark:!bg-gray-100"
|
||||
defaultValue={activeResponse?.body}
|
||||
contentType={contentType}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{!activeResponse ? null : activeResponse?.error ? (
|
||||
<div className="p-1">
|
||||
<div className="text-white bg-red-500 px-3 py-3 rounded">{activeResponse.error}</div>
|
||||
</div>
|
||||
) : viewMode === 'pretty' && contentType.includes('html') ? (
|
||||
<Webview body={activeResponse.body} contentType={contentType} url={activeResponse.url} />
|
||||
) : viewMode === 'pretty' && contentType.includes('json') ? (
|
||||
<Editor
|
||||
readOnly
|
||||
key={`${contentType}:${activeResponse.updatedAt}:pretty`}
|
||||
className="bg-gray-50 dark:!bg-gray-100"
|
||||
defaultValue={tryFormatJson(activeResponse?.body)}
|
||||
contentType={contentType}
|
||||
/>
|
||||
) : activeResponse?.body ? (
|
||||
<Editor
|
||||
readOnly
|
||||
key={`${contentType}:${activeResponse.updatedAt}`}
|
||||
className="bg-gray-50 dark:!bg-gray-100"
|
||||
defaultValue={activeResponse?.body}
|
||||
contentType={contentType}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
16
src-web/components/SidebarDisplayToggle.tsx
Normal file
16
src-web/components/SidebarDisplayToggle.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { memo } from 'react';
|
||||
import { useSidebarDisplay } from '../hooks/useSidebarDisplay';
|
||||
import { IconButton } from './core/IconButton';
|
||||
|
||||
export const SidebarDisplayToggle = memo(function SidebarDisplayToggle() {
|
||||
const sidebarDisplay = useSidebarDisplay();
|
||||
return (
|
||||
<IconButton
|
||||
onClick={sidebarDisplay.toggle}
|
||||
className="pointer-events-auto"
|
||||
size="sm"
|
||||
title="Show sidebar"
|
||||
icon="hamburger"
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,68 +1,20 @@
|
||||
import classnames from 'classnames';
|
||||
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode } from 'react';
|
||||
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useWindowSize } from 'react-use';
|
||||
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useSidebarDisplay } from '../hooks/useSidebarDisplay';
|
||||
import { RequestResponse } from './RequestResponse';
|
||||
import { ResizeHandle } from './ResizeHandle';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { WorkspaceHeader } from './WorkspaceHeader';
|
||||
import RequestResponse from './RequestResponse';
|
||||
|
||||
const side = { gridArea: 'side' };
|
||||
const head = { gridArea: 'head' };
|
||||
const body = { gridArea: 'body' };
|
||||
|
||||
const FLOATING_SIDEBAR_BREAKPOINT = 960;
|
||||
const drag = { gridArea: 'drag' };
|
||||
|
||||
export default function Workspace() {
|
||||
const windowSize = useWindowSize();
|
||||
const styles = useMemo<CSSProperties>(
|
||||
() => ({
|
||||
gridTemplate: `
|
||||
' ${head.gridArea} ${head.gridArea}' auto
|
||||
' ${side.gridArea} ${body.gridArea}' minmax(0,1fr)
|
||||
/ auto 1fr
|
||||
`,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid w-full h-full" style={styles}>
|
||||
<SidebarContainer style={side} floating={windowSize.width < FLOATING_SIDEBAR_BREAKPOINT}>
|
||||
<Sidebar />
|
||||
</SidebarContainer>
|
||||
<HeaderContainer>
|
||||
<WorkspaceHeader className="pointer-events-none" />
|
||||
</HeaderContainer>
|
||||
<RequestResponse style={body} vertical={windowSize.width < FLOATING_SIDEBAR_BREAKPOINT} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const HeaderContainer = memo(function HeaderContainer({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="h-md px-3 w-full pl-20 bg-gray-50 border-b border-b-highlight text-gray-900 pt-[1px]"
|
||||
style={head}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface SidebarContainerProps {
|
||||
children: ReactNode;
|
||||
style: CSSProperties;
|
||||
floating?: boolean;
|
||||
}
|
||||
|
||||
const SidebarContainer = memo(function SidebarContainer({
|
||||
children,
|
||||
style,
|
||||
floating,
|
||||
}: SidebarContainerProps) {
|
||||
const sidebar = useSidebarDisplay();
|
||||
|
||||
const [isResizing, setIsResizing] = useState<boolean>(false);
|
||||
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
|
||||
null,
|
||||
@@ -83,7 +35,7 @@ const SidebarContainer = memo(function SidebarContainer({
|
||||
const mouseStartX = e.clientX;
|
||||
const startWidth = sidebar.width;
|
||||
moveState.current = {
|
||||
move: (e: MouseEvent) => {
|
||||
move: async (e: MouseEvent) => {
|
||||
e.preventDefault(); // Prevent text selection and things
|
||||
sidebar.set(startWidth + (e.clientX - mouseStartX));
|
||||
},
|
||||
@@ -97,96 +49,44 @@ const SidebarContainer = memo(function SidebarContainer({
|
||||
document.documentElement.addEventListener('mouseup', moveState.current.up);
|
||||
setIsResizing(true);
|
||||
},
|
||||
[sidebar.width],
|
||||
[sidebar.width, sidebar.hidden],
|
||||
);
|
||||
|
||||
const sidebarStyles = useMemo(
|
||||
const sideWidth = sidebar.hidden ? 0 : sidebar.width;
|
||||
const styles = useMemo<CSSProperties>(
|
||||
() => ({
|
||||
width: sidebar.hidden ? 0 : sidebar.width, // No width when hidden
|
||||
borderWidth: sidebar.hidden ? 0 : undefined, // No border when hidden
|
||||
gridTemplate: `
|
||||
' ${head.gridArea} ${head.gridArea} ${head.gridArea}' auto
|
||||
' ${side.gridArea} ${drag.gridArea} ${body.gridArea}' minmax(0,1fr)
|
||||
/ ${sideWidth}px 0 1fr`,
|
||||
}),
|
||||
[sidebar.width, sidebar.hidden, style],
|
||||
[sideWidth],
|
||||
);
|
||||
|
||||
const commonClassname = classnames('overflow-hidden bg-gray-100 border-highlight');
|
||||
|
||||
if (floating) {
|
||||
return (
|
||||
<div style={style}>
|
||||
<div
|
||||
style={sidebarStyles}
|
||||
className={classnames(
|
||||
commonClassname,
|
||||
'fixed top-11 z-20 left-1 bottom-1 border rounded-md shadow-lg',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classnames(commonClassname, 'relative h-full border-r')} style={sidebarStyles}>
|
||||
<ResizeBar
|
||||
<div className="grid w-full h-full" style={styles}>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="h-md px-3 w-full pl-20 bg-gray-50 border-b border-b-highlight text-gray-900 pt-[1px]"
|
||||
style={head}
|
||||
>
|
||||
<WorkspaceHeader className="pointer-events-none" />
|
||||
</div>
|
||||
<div
|
||||
style={side}
|
||||
className={classnames('overflow-hidden bg-gray-100 border-r border-highlight')}
|
||||
>
|
||||
<Sidebar />
|
||||
</div>
|
||||
<ResizeHandle
|
||||
className="-translate-x-3"
|
||||
justify="end"
|
||||
side="right"
|
||||
isResizing={isResizing}
|
||||
onResizeStart={handleResizeStart}
|
||||
onReset={sidebar.reset}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface ResizeBarProps {
|
||||
className?: string;
|
||||
barClassName?: string;
|
||||
isResizing: boolean;
|
||||
onResizeStart: (e: ReactMouseEvent<HTMLDivElement>) => void;
|
||||
onReset?: () => void;
|
||||
side: 'left' | 'right' | 'top';
|
||||
justify: 'center' | 'end' | 'start';
|
||||
}
|
||||
|
||||
export function ResizeBar({
|
||||
justify,
|
||||
className,
|
||||
onResizeStart,
|
||||
onReset,
|
||||
isResizing,
|
||||
side,
|
||||
}: ResizeBarProps) {
|
||||
const vertical = side === 'top';
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
draggable
|
||||
className={classnames(
|
||||
className,
|
||||
'group absolute z-10 flex cursor-ew-resize',
|
||||
vertical ? 'w-full h-3 cursor-ns-resize' : 'h-full w-3 cursor-ew-resize',
|
||||
justify === 'center' && 'justify-center',
|
||||
justify === 'end' && 'justify-end',
|
||||
justify === 'start' && 'justify-start',
|
||||
side === 'right' && 'right-0',
|
||||
side === 'left' && 'left-0',
|
||||
side === 'top' && 'top-0',
|
||||
)}
|
||||
onDragStart={onResizeStart}
|
||||
onDoubleClick={onReset}
|
||||
>
|
||||
{/* Show global overlay with cursor style to ensure cursor remains the same when moving quickly */}
|
||||
{isResizing && (
|
||||
<div
|
||||
className={classnames(
|
||||
'fixed inset-0 cursor-ew-resize',
|
||||
vertical && 'cursor-ns-resize',
|
||||
!vertical && 'cursor-ew-resize',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<RequestResponse style={body} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,23 @@
|
||||
import classnames from 'classnames';
|
||||
import { memo } from 'react';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { useSidebarDisplay } from '../hooks/useSidebarDisplay';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { HStack } from './core/Stacks';
|
||||
import { RequestSettingsDropdown } from './RequestSettingsDropdown';
|
||||
import { SidebarDisplayToggle } from './SidebarDisplayToggle';
|
||||
import { WorkspaceDropdown } from './WorkspaceDropdown';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WorkspaceHeader({ className }: Props) {
|
||||
export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Props) {
|
||||
const activeRequest = useActiveRequest();
|
||||
const sidebarDisplay = useSidebarDisplay();
|
||||
return (
|
||||
<HStack justifyContent="center" alignItems="center" className={classnames(className, 'h-full')}>
|
||||
<HStack className="flex-1 -ml-2 pointer-events-none" alignItems="center">
|
||||
<IconButton
|
||||
onClick={sidebarDisplay.toggle}
|
||||
className="pointer-events-auto"
|
||||
size="sm"
|
||||
title="Show sidebar"
|
||||
icon="hamburger"
|
||||
/>
|
||||
<SidebarDisplayToggle />
|
||||
<WorkspaceDropdown className="pointer-events-auto" />
|
||||
</HStack>
|
||||
<div className="flex-[2] text-center text-gray-800 text-sm truncate pointer-events-none">
|
||||
@@ -43,4 +38,4 @@ export function WorkspaceHeader({ className }: Props) {
|
||||
</div>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -125,9 +125,9 @@ export const PairEditor = memo(function PairEditor({
|
||||
className={classnames(
|
||||
className,
|
||||
'@container',
|
||||
'pb-2 grid',
|
||||
// NOTE: Add padding to top so overflow doesn't hide drop marker
|
||||
'pt-1 -my-1',
|
||||
'overflow-auto max-h-full pb-2 grid',
|
||||
// Move over the width of the drag handle
|
||||
'-ml-3',
|
||||
)}
|
||||
>
|
||||
{pairs.map((p, i) => {
|
||||
|
||||
@@ -127,7 +127,7 @@ export const TabContent = memo(function TabContent({
|
||||
<div
|
||||
tabIndex={-1}
|
||||
data-tab={value}
|
||||
className={classnames(className, 'tab-content', 'w-full h-full overflow-auto')}
|
||||
className={classnames(className, 'tab-content', 'w-full h-full')}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
import { buildKeyValueKey, getKeyValue, setKeyValue } from '../lib/keyValueStore';
|
||||
|
||||
const DEFAULT_NAMESPACE = 'app';
|
||||
@@ -13,7 +14,8 @@ export function keyValueQueryKey({
|
||||
return ['key_value', { namespace, key: buildKeyValueKey(key) }];
|
||||
}
|
||||
|
||||
export function useKeyValue<T extends string | number | boolean>({
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
export function useKeyValue<T extends Object>({
|
||||
namespace = DEFAULT_NAMESPACE,
|
||||
key,
|
||||
defaultValue,
|
||||
@@ -31,9 +33,23 @@ export function useKeyValue<T extends string | number | boolean>({
|
||||
mutationFn: (value) => setKeyValue<T>({ namespace, key, value }),
|
||||
});
|
||||
|
||||
const set = useCallback(
|
||||
(value: ((v: T) => T) | T) => {
|
||||
if (typeof value === 'function') {
|
||||
mutate.mutate(value(query.data ?? defaultValue));
|
||||
} else {
|
||||
mutate.mutate(value);
|
||||
}
|
||||
},
|
||||
[query.data, defaultValue],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => mutate.mutate(defaultValue), [defaultValue]);
|
||||
|
||||
return {
|
||||
value: query.data,
|
||||
isLoading: query.isLoading,
|
||||
set: (value: T) => mutate.mutate(value),
|
||||
set,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,27 +1,37 @@
|
||||
import { useCallback } from 'react';
|
||||
import { clamp } from '../lib/clamp';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useKeyValue } from './useKeyValue';
|
||||
|
||||
const START_WIDTH = 200;
|
||||
const MIN_WIDTH = 110;
|
||||
const MAX_WIDTH = 500;
|
||||
const MIN_WIDTH = 150;
|
||||
const COLLAPSE_WIDTH = MIN_WIDTH * 0.25;
|
||||
|
||||
export enum SidebarDisplayKeys {
|
||||
width = 'sidebar_width',
|
||||
hidden = 'sidebar_hidden',
|
||||
export const sidebarDisplayKey = 'sidebar_display';
|
||||
export const sidebarDisplayDefaultValue: SidebarDisplay = { hidden: false, width: START_WIDTH };
|
||||
|
||||
export interface SidebarDisplay {
|
||||
hidden: boolean;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export function useSidebarDisplay() {
|
||||
const hiddenKv = useKeyValue<boolean>({ key: SidebarDisplayKeys.hidden, defaultValue: false });
|
||||
const widthKv = useKeyValue<number>({ key: SidebarDisplayKeys.width, defaultValue: START_WIDTH });
|
||||
const hidden = hiddenKv.value;
|
||||
const width = widthKv.value ?? START_WIDTH;
|
||||
const display = useKeyValue<SidebarDisplay>({
|
||||
key: sidebarDisplayKey,
|
||||
defaultValue: sidebarDisplayDefaultValue,
|
||||
});
|
||||
const hidden = display.value?.hidden ?? false;
|
||||
const width = display.value?.width ?? START_WIDTH;
|
||||
|
||||
const set = useCallback((v: number) => widthKv.set(clamp(v, MIN_WIDTH, MAX_WIDTH)), []);
|
||||
const reset = useCallback(() => widthKv.set(START_WIDTH), []);
|
||||
const hide = useCallback(() => hiddenKv.set(true), []);
|
||||
const show = useCallback(() => hiddenKv.set(false), []);
|
||||
const toggle = useCallback(() => hiddenKv.set(!hiddenKv.value), [hiddenKv.value]);
|
||||
const set = useCallback(
|
||||
(width: number) => {
|
||||
const hidden = width < COLLAPSE_WIDTH;
|
||||
display.set({ hidden, width: Math.max(MIN_WIDTH, width) });
|
||||
},
|
||||
[display.set],
|
||||
);
|
||||
const hide = useCallback(() => display.set((v) => ({ ...v, hidden: true })), [display.set]);
|
||||
const show = useCallback(() => display.set((v) => ({ ...v, hidden: false })), [display.set]);
|
||||
const toggle = useMemo(() => (hidden ? show : hide), [hidden, show, hide]);
|
||||
const reset = display.reset;
|
||||
|
||||
return { width, hidden, set, reset, hide, show, toggle };
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ import type { KeyValue } from './models';
|
||||
|
||||
const DEFAULT_NAMESPACE = 'app';
|
||||
|
||||
type KeyValueValue = string | number | boolean;
|
||||
|
||||
export async function setKeyValue<T>({
|
||||
namespace = DEFAULT_NAMESPACE,
|
||||
key,
|
||||
@@ -22,7 +20,7 @@ export async function setKeyValue<T>({
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function getKeyValue<T extends KeyValueValue>({
|
||||
export async function getKeyValue<T>({
|
||||
namespace = DEFAULT_NAMESPACE,
|
||||
key,
|
||||
fallback,
|
||||
@@ -38,7 +36,7 @@ export async function getKeyValue<T extends KeyValueValue>({
|
||||
return extractKeyValueOrFallback(kv, fallback);
|
||||
}
|
||||
|
||||
export function extractKeyValue<T extends KeyValueValue>(kv: KeyValue | null): T | undefined {
|
||||
export function extractKeyValue<T>(kv: KeyValue | null): T | undefined {
|
||||
if (kv === null) return undefined;
|
||||
try {
|
||||
return JSON.parse(kv.value) as T;
|
||||
@@ -47,10 +45,7 @@ export function extractKeyValue<T extends KeyValueValue>(kv: KeyValue | null): T
|
||||
}
|
||||
}
|
||||
|
||||
export function extractKeyValueOrFallback<T extends KeyValueValue>(
|
||||
kv: KeyValue | null,
|
||||
fallback: T,
|
||||
): T {
|
||||
export function extractKeyValueOrFallback<T>(kv: KeyValue | null, fallback: T): T {
|
||||
const v = extractKeyValue<T>(kv);
|
||||
if (v === undefined) return fallback;
|
||||
return v;
|
||||
|
||||
Reference in New Issue
Block a user