mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-24 09:48:28 +02:00
Global layout component
This commit is contained in:
@@ -459,12 +459,13 @@ fn main() {
|
|||||||
.add_item(CustomMenuItem::new("send_request".to_string(), "Send Request").accelerator("CmdOrCtrl+r"))
|
.add_item(CustomMenuItem::new("send_request".to_string(), "Send Request").accelerator("CmdOrCtrl+r"))
|
||||||
.add_item(CustomMenuItem::new("zoom_reset".to_string(), "Zoom to Actual Size").accelerator("CmdOrCtrl+0"))
|
.add_item(CustomMenuItem::new("zoom_reset".to_string(), "Zoom to Actual Size").accelerator("CmdOrCtrl+0"))
|
||||||
.add_item(CustomMenuItem::new("zoom_in".to_string(), "Zoom In").accelerator("CmdOrCtrl+Plus"))
|
.add_item(CustomMenuItem::new("zoom_in".to_string(), "Zoom In").accelerator("CmdOrCtrl+Plus"))
|
||||||
.add_item(CustomMenuItem::new("zoom_out".to_string(), "Zoom Out").accelerator("CmdOrCtrl+-"));
|
.add_item(CustomMenuItem::new("zoom_out".to_string(), "Zoom Out").accelerator("CmdOrCtrl+-"))
|
||||||
|
.add_item(CustomMenuItem::new("toggle_sidebar".to_string(), "Toggle Sidebar").accelerator("CmdOrCtrl+b"));
|
||||||
if is_dev() {
|
if is_dev() {
|
||||||
test_menu = test_menu
|
test_menu = test_menu
|
||||||
.add_native_item(MenuItem::Separator)
|
.add_native_item(MenuItem::Separator)
|
||||||
.add_item(CustomMenuItem::new("refresh".to_string(), "Refresh").accelerator("CmdOrCtrl+Shift+r"))
|
.add_item(CustomMenuItem::new("refresh".to_string(), "Refresh").accelerator("CmdOrCtrl + Shift + r"))
|
||||||
.add_item(CustomMenuItem::new("toggle_devtools".to_string(), "Open Devtools").accelerator("CmdOrCtrl+Option+i"));
|
.add_item(CustomMenuItem::new("toggle_devtools".to_string(), "Open Devtools").accelerator("CmdOrCtrl + Option + i"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let submenu = Submenu::new("Test Menu", test_menu);
|
let submenu = Submenu::new("Test Menu", test_menu);
|
||||||
@@ -490,7 +491,7 @@ fn main() {
|
|||||||
|
|
||||||
create_dir_all(dir.clone()).expect("Problem creating App directory!");
|
create_dir_all(dir.clone()).expect("Problem creating App directory!");
|
||||||
let p = dir.join("db.sqlite");
|
let p = dir.join("db.sqlite");
|
||||||
let p_string = p.to_string_lossy().replace(' ', "%20");
|
let p_string = p.to_string_lossy().replace(' ', " % 20");
|
||||||
let url = format!("sqlite://{}?mode=rwc", p_string);
|
let url = format!("sqlite://{}?mode=rwc", p_string);
|
||||||
tauri::async_runtime::block_on(async move {
|
tauri::async_runtime::block_on(async move {
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = SqlitePoolOptions::new()
|
||||||
@@ -526,6 +527,7 @@ fn main() {
|
|||||||
"zoom_reset" => event.window().emit("zoom", 0).unwrap(),
|
"zoom_reset" => event.window().emit("zoom", 0).unwrap(),
|
||||||
"zoom_in" => event.window().emit("zoom", 1).unwrap(),
|
"zoom_in" => event.window().emit("zoom", 1).unwrap(),
|
||||||
"zoom_out" => event.window().emit("zoom", -1).unwrap(),
|
"zoom_out" => event.window().emit("zoom", -1).unwrap(),
|
||||||
|
"toggle_sidebar" => event.window().emit("toggle_sidebar", true).unwrap(),
|
||||||
"refresh" => event.window().emit("refresh", true).unwrap(),
|
"refresh" => event.window().emit("refresh", true).unwrap(),
|
||||||
"send_request" => event.window().emit("send_request", true).unwrap(),
|
"send_request" => event.window().emit("send_request", true).unwrap(),
|
||||||
"toggle_devtools" => {
|
"toggle_devtools" => {
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import { keyValueQueryKey } from '../hooks/useKeyValue';
|
|||||||
import { requestsQueryKey } from '../hooks/useRequests';
|
import { requestsQueryKey } from '../hooks/useRequests';
|
||||||
import { responsesQueryKey } from '../hooks/useResponses';
|
import { responsesQueryKey } from '../hooks/useResponses';
|
||||||
import { routePaths } from '../hooks/useRoutes';
|
import { routePaths } from '../hooks/useRoutes';
|
||||||
|
import { SidebarDisplayKeys } from '../hooks/useSidebarDisplay';
|
||||||
import { workspacesQueryKey } from '../hooks/useWorkspaces';
|
import { workspacesQueryKey } from '../hooks/useWorkspaces';
|
||||||
import { DEFAULT_FONT_SIZE } from '../lib/constants';
|
import { DEFAULT_FONT_SIZE } from '../lib/constants';
|
||||||
import { extractKeyValue } from '../lib/keyValueStore';
|
import { extractKeyValue, getKeyValue, setKeyValue } from '../lib/keyValueStore';
|
||||||
import type { HttpRequest, HttpResponse, KeyValue, Workspace } from '../lib/models';
|
import type { HttpRequest, HttpResponse, KeyValue, Workspace } from '../lib/models';
|
||||||
import { AppRouter } from './AppRouter';
|
import { AppRouter } from './AppRouter';
|
||||||
|
|
||||||
@@ -137,6 +138,11 @@ await listen('refresh', () => {
|
|||||||
location.reload();
|
location.reload();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await listen('toggle_sidebar', async () => {
|
||||||
|
const hidden = await getKeyValue<boolean>({ key: SidebarDisplayKeys.hidden, fallback: false });
|
||||||
|
await setKeyValue({ key: SidebarDisplayKeys.hidden, value: !hidden });
|
||||||
|
});
|
||||||
|
|
||||||
await listen('zoom', ({ payload: zoomDelta }: { payload: number }) => {
|
await listen('zoom', ({ payload: zoomDelta }: { payload: number }) => {
|
||||||
const fontSize = parseFloat(window.getComputedStyle(document.documentElement).fontSize);
|
const fontSize = parseFloat(window.getComputedStyle(document.documentElement).fontSize);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { lazy, Suspense } from 'react';
|
import { lazy, Suspense } from 'react';
|
||||||
import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom';
|
import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom';
|
||||||
import { routePaths } from '../hooks/useRoutes';
|
import { routePaths } from '../hooks/useRoutes';
|
||||||
|
import { WorkspaceLayout } from './layouts/WorkspaceLayout';
|
||||||
|
|
||||||
const Workspaces = lazy(() => import('./Workspaces'));
|
const Workspaces = lazy(() => import('./Workspaces'));
|
||||||
const Workspace = lazy(() => import('./Workspace'));
|
const Workspace = lazy(() => import('./Workspace'));
|
||||||
@@ -21,14 +22,22 @@ const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: routePaths.workspace({ workspaceId: ':workspaceId' }),
|
path: routePaths.workspace({ workspaceId: ':workspaceId' }),
|
||||||
element: <Workspace />,
|
element: (
|
||||||
|
<WorkspaceLayout>
|
||||||
|
<Workspace />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: routePaths.request({
|
path: routePaths.request({
|
||||||
workspaceId: ':workspaceId',
|
workspaceId: ':workspaceId',
|
||||||
requestId: ':requestId',
|
requestId: ':requestId',
|
||||||
}),
|
}),
|
||||||
element: <Workspace />,
|
element: (
|
||||||
|
<WorkspaceLayout>
|
||||||
|
<Workspace />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -77,7 +77,12 @@ export function RequestPane({ fullHeight, className }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classnames(className, 'py-3 grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}>
|
<div
|
||||||
|
className={classnames(
|
||||||
|
className,
|
||||||
|
'h-full py-3 grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{activeRequest && (
|
{activeRequest && (
|
||||||
<>
|
<>
|
||||||
<UrlBar
|
<UrlBar
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { memo, useEffect, useMemo, useState } from 'react';
|
import { memo, useEffect, useMemo, useState } from 'react';
|
||||||
import { useActiveRequestId } from '../hooks/useActiveRequestId';
|
import { useActiveRequestId } from '../hooks/useActiveRequestId';
|
||||||
import { useDeleteResponses } from '../hooks/useDeleteResponses';
|
|
||||||
import { useDeleteResponse } from '../hooks/useDeleteResponse';
|
import { useDeleteResponse } from '../hooks/useDeleteResponse';
|
||||||
|
import { useDeleteResponses } from '../hooks/useDeleteResponses';
|
||||||
import { useResponses } from '../hooks/useResponses';
|
import { useResponses } from '../hooks/useResponses';
|
||||||
import { useResponseViewMode } from '../hooks/useResponseViewMode';
|
import { useResponseViewMode } from '../hooks/useResponseViewMode';
|
||||||
import { tryFormatJson } from '../lib/formatters';
|
import { tryFormatJson } from '../lib/formatters';
|
||||||
@@ -16,11 +16,7 @@ import { HStack } from './core/Stacks';
|
|||||||
import { StatusColor } from './core/StatusColor';
|
import { StatusColor } from './core/StatusColor';
|
||||||
import { Webview } from './core/Webview';
|
import { Webview } from './core/Webview';
|
||||||
|
|
||||||
interface Props {
|
export const ResponsePane = memo(function ResponsePane() {
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ResponsePane = memo(function ResponsePane({ className }: Props) {
|
|
||||||
const [pinnedResponseId, setPinnedResponseId] = useState<string | null>(null);
|
const [pinnedResponseId, setPinnedResponseId] = useState<string | null>(null);
|
||||||
const activeRequestId = useActiveRequestId();
|
const activeRequestId = useActiveRequestId();
|
||||||
const responses = useResponses(activeRequestId);
|
const responses = useResponses(activeRequestId);
|
||||||
@@ -43,7 +39,7 @@ export const ResponsePane = memo(function ResponsePane({ className }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classnames(className, 'p-3')}>
|
<div className={classnames('h-full w-full p-3')}>
|
||||||
<div
|
<div
|
||||||
className={classnames(
|
className={classnames(
|
||||||
'max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 ',
|
'max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 ',
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import type { ForwardedRef, KeyboardEvent, MouseEvent as ReactMouseEvent } from 'react';
|
import type { ForwardedRef, KeyboardEvent } from 'react';
|
||||||
import React, { forwardRef, Fragment, memo, useCallback, useMemo, useRef, useState } from 'react';
|
import React, { forwardRef, Fragment, memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import type { XYCoord } from 'react-dnd';
|
import type { XYCoord } from 'react-dnd';
|
||||||
import { useDrag, useDrop } from 'react-dnd';
|
import { useDrag, useDrop } from 'react-dnd';
|
||||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||||
import { useCreateRequest } from '../hooks/useCreateRequest';
|
|
||||||
import { useRequests } from '../hooks/useRequests';
|
import { useRequests } from '../hooks/useRequests';
|
||||||
import { useSidebarWidth } from '../hooks/useSidebarWidth';
|
|
||||||
import { useUpdateAnyRequest } from '../hooks/useUpdateAnyRequest';
|
import { useUpdateAnyRequest } from '../hooks/useUpdateAnyRequest';
|
||||||
import { useUpdateRequest } from '../hooks/useUpdateRequest';
|
import { useUpdateRequest } from '../hooks/useUpdateRequest';
|
||||||
import type { HttpRequest } from '../lib/models';
|
import type { HttpRequest } from '../lib/models';
|
||||||
import { Button } from './core/Button';
|
import { Button } from './core/Button';
|
||||||
import { IconButton } from './core/IconButton';
|
import { IconButton } from './core/IconButton';
|
||||||
import { HStack, VStack } from './core/Stacks';
|
import { HStack, VStack } from './core/Stacks';
|
||||||
import { WindowDragRegion } from './core/WindowDragRegion';
|
|
||||||
import { DropMarker } from './DropMarker';
|
import { DropMarker } from './DropMarker';
|
||||||
import { RequestSettingsDropdown } from './RequestSettingsDropdown';
|
import { RequestSettingsDropdown } from './RequestSettingsDropdown';
|
||||||
import { ToggleThemeButton } from './ToggleThemeButton';
|
import { ToggleThemeButton } from './ToggleThemeButton';
|
||||||
@@ -27,80 +24,20 @@ enum ItemTypes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Sidebar = memo(function Sidebar({ className }: Props) {
|
export const Sidebar = memo(function Sidebar({ className }: Props) {
|
||||||
const [isResizing, setIsResizing] = useState<boolean>(false);
|
|
||||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||||
const unorderedRequests = useRequests();
|
const unorderedRequests = useRequests();
|
||||||
const activeRequest = useActiveRequest();
|
const activeRequest = useActiveRequest();
|
||||||
const createRequest = useCreateRequest({ navigateAfter: true });
|
|
||||||
const width = useSidebarWidth();
|
|
||||||
const requests = useMemo(
|
const requests = useMemo(
|
||||||
() => [...unorderedRequests].sort((a, b) => a.sortPriority - b.sortPriority),
|
() => [...unorderedRequests].sort((a, b) => a.sortPriority - b.sortPriority),
|
||||||
[unorderedRequests],
|
[unorderedRequests],
|
||||||
);
|
);
|
||||||
|
|
||||||
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const unsub = () => {
|
|
||||||
if (moveState.current !== null) {
|
|
||||||
document.documentElement.removeEventListener('mousemove', moveState.current.move);
|
|
||||||
document.documentElement.removeEventListener('mouseup', moveState.current.up);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResizeStart = useCallback(
|
|
||||||
(e: ReactMouseEvent<HTMLDivElement>) => {
|
|
||||||
if (width.value === undefined) return;
|
|
||||||
|
|
||||||
unsub();
|
|
||||||
const mouseStartX = e.clientX;
|
|
||||||
const startWidth = width.value;
|
|
||||||
moveState.current = {
|
|
||||||
move: (e: MouseEvent) => {
|
|
||||||
e.preventDefault(); // Prevent text selection and things
|
|
||||||
width.set(startWidth + (e.clientX - mouseStartX));
|
|
||||||
},
|
|
||||||
up: (e: MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
unsub();
|
|
||||||
setIsResizing(false);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
document.documentElement.addEventListener('mousemove', moveState.current.move);
|
|
||||||
document.documentElement.addEventListener('mouseup', moveState.current.up);
|
|
||||||
setIsResizing(true);
|
|
||||||
},
|
|
||||||
[width.value],
|
|
||||||
);
|
|
||||||
|
|
||||||
const sidebarStyles = useMemo(() => ({ width: width.value }), [width.value]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative h-full">
|
||||||
<ResizeBar isResizing={isResizing} onResizeStart={handleResizeStart} onReset={width.reset} />
|
|
||||||
<div
|
<div
|
||||||
ref={sidebarRef}
|
ref={sidebarRef}
|
||||||
style={sidebarStyles}
|
className={classnames(className, 'h-full relative grid grid-rows-[minmax(0,1fr)_auto]')}
|
||||||
className={classnames(
|
|
||||||
className,
|
|
||||||
'bg-gray-100 h-full border-r border-highlight relative grid grid-rows-[auto_minmax(0,1fr)_auto]',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<HStack as={WindowDragRegion} alignItems="center" justifyContent="end">
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
title="Add Request"
|
|
||||||
className="mx-1"
|
|
||||||
icon="plusCircle"
|
|
||||||
onClick={async () => {
|
|
||||||
const lastRequest = requests[requests.length - 1];
|
|
||||||
await createRequest.mutate({
|
|
||||||
name: 'Test Request',
|
|
||||||
sortPriority: (lastRequest?.sortPriority ?? 0) + 1,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
<VStack as="ul" className="relative py-3 overflow-auto" draggable={false}>
|
<VStack as="ul" className="relative py-3 overflow-auto" draggable={false}>
|
||||||
<SidebarItems activeRequestId={activeRequest?.id} requests={requests} />
|
<SidebarItems activeRequestId={activeRequest?.id} requests={requests} />
|
||||||
</VStack>
|
</VStack>
|
||||||
@@ -346,30 +283,3 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
interface ResizeBarProps {
|
|
||||||
isResizing: boolean;
|
|
||||||
onResizeStart: (e: ReactMouseEvent<HTMLDivElement>) => void;
|
|
||||||
onReset: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResizeBar({ onResizeStart, onReset, isResizing }: ResizeBarProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
draggable
|
|
||||||
className="group absolute z-10 -right-0.5 w-3 top-0 bottom-0 flex justify-end cursor-ew-resize"
|
|
||||||
onDragStart={onResizeStart}
|
|
||||||
onDoubleClick={onReset}
|
|
||||||
>
|
|
||||||
{/* Show global overlay with cursor style to ensure cursor remains the same when moving quickly */}
|
|
||||||
{isResizing && <div className="fixed inset-0 cursor-ew-resize" />}
|
|
||||||
<div // drag-divider
|
|
||||||
className={classnames(
|
|
||||||
'transition-colors w-1 mr-0.5 group-hover:bg-highlight h-full pointer-events-none',
|
|
||||||
isResizing && '!bg-blue-500/70',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
import classnames from 'classnames';
|
|
||||||
import { useMemo, useRef } from 'react';
|
|
||||||
import { useWindowSize } from 'react-use';
|
|
||||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
|
||||||
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
|
|
||||||
import { useSidebarWidth } from '../hooks/useSidebarWidth';
|
|
||||||
import { IconButton } from './core/IconButton';
|
|
||||||
import { HStack } from './core/Stacks';
|
|
||||||
import { WindowDragRegion } from './core/WindowDragRegion';
|
|
||||||
import { RequestPane } from './RequestPane';
|
|
||||||
import { RequestSettingsDropdown } from './RequestSettingsDropdown';
|
|
||||||
import { ResponsePane } from './ResponsePane';
|
|
||||||
import { Sidebar } from './Sidebar';
|
|
||||||
import { WorkspaceDropdown } from './WorkspaceDropdown';
|
|
||||||
|
|
||||||
export default function Workspace() {
|
|
||||||
const activeRequest = useActiveRequest();
|
|
||||||
const activeWorkspace = useActiveWorkspace();
|
|
||||||
|
|
||||||
const mainContentRef = useRef<HTMLDivElement>(null);
|
|
||||||
const windowSize = useWindowSize();
|
|
||||||
const sidebarWidth = useSidebarWidth();
|
|
||||||
|
|
||||||
const mainContentWidth = useMemo(() => {
|
|
||||||
return mainContentRef.current?.getBoundingClientRect().width ?? 0;
|
|
||||||
// TODO: Use container query subscription instead of minitoring everything
|
|
||||||
}, [mainContentRef.current, windowSize, sidebarWidth.value]);
|
|
||||||
|
|
||||||
const isSideBySide = mainContentWidth > 700;
|
|
||||||
|
|
||||||
if (activeWorkspace == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-[auto_1fr] grid-rows-1 h-full text-gray-900">
|
|
||||||
<Sidebar />
|
|
||||||
<div ref={mainContentRef} className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
|
|
||||||
<HStack
|
|
||||||
as={WindowDragRegion}
|
|
||||||
justifyContent="center"
|
|
||||||
className="px-3 bg-gray-50 text-gray-900 border-b border-b-highlight pt-[1px]"
|
|
||||||
alignItems="center"
|
|
||||||
>
|
|
||||||
<div className="flex-1 -ml-2 pointer-events-none">
|
|
||||||
<WorkspaceDropdown className="pointer-events-auto" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-[2] text-center text-gray-700 text-sm truncate pointer-events-none">
|
|
||||||
{activeRequest?.name}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 flex justify-end -mr-2 pointer-events-none">
|
|
||||||
<IconButton size="sm" title="" icon="magnifyingGlass" />
|
|
||||||
<RequestSettingsDropdown>
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
title="Request Options"
|
|
||||||
icon="gear"
|
|
||||||
className="pointer-events-auto"
|
|
||||||
/>
|
|
||||||
</RequestSettingsDropdown>
|
|
||||||
</div>
|
|
||||||
</HStack>
|
|
||||||
<div
|
|
||||||
className={classnames(
|
|
||||||
'grid',
|
|
||||||
isSideBySide
|
|
||||||
? 'grid-cols-[1fr_1fr] grid-rows-[minmax(0,1fr)]'
|
|
||||||
: 'grid-cols-1 grid-rows-[minmax(0,auto)_minmax(0,100%)]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<RequestPane
|
|
||||||
fullHeight={isSideBySide}
|
|
||||||
className={classnames(isSideBySide ? 'pr-1.5' : 'pr-3 pb-0')}
|
|
||||||
/>
|
|
||||||
<ResponsePane className={classnames(isSideBySide ? 'pl-1.5' : 'mt-0')} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
46
src-web/components/WorkspaceHeader.tsx
Normal file
46
src-web/components/WorkspaceHeader.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import classnames from 'classnames';
|
||||||
|
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 { WorkspaceDropdown } from './WorkspaceDropdown';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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"
|
||||||
|
/>
|
||||||
|
<WorkspaceDropdown className="pointer-events-auto" />
|
||||||
|
</HStack>
|
||||||
|
<div className="flex-[2] text-center text-gray-700 text-sm truncate pointer-events-none">
|
||||||
|
{activeRequest?.name}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex justify-end -mr-2 pointer-events-none">
|
||||||
|
<IconButton size="sm" title="" icon="magnifyingGlass" />
|
||||||
|
{activeRequest && (
|
||||||
|
<RequestSettingsDropdown requestId={activeRequest?.id}>
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
title="Request Options"
|
||||||
|
icon="gear"
|
||||||
|
className="pointer-events-auto"
|
||||||
|
/>
|
||||||
|
</RequestSettingsDropdown>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
DragHandleDots2Icon,
|
DragHandleDots2Icon,
|
||||||
EyeOpenIcon,
|
EyeOpenIcon,
|
||||||
GearIcon,
|
GearIcon,
|
||||||
|
HamburgerMenuIcon,
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
ListBulletIcon,
|
ListBulletIcon,
|
||||||
MagicWandIcon,
|
MagicWandIcon,
|
||||||
@@ -51,6 +52,7 @@ const icons = {
|
|||||||
drag: DragHandleDots2Icon,
|
drag: DragHandleDots2Icon,
|
||||||
eye: EyeOpenIcon,
|
eye: EyeOpenIcon,
|
||||||
gear: GearIcon,
|
gear: GearIcon,
|
||||||
|
hamburger: HamburgerMenuIcon,
|
||||||
home: HomeIcon,
|
home: HomeIcon,
|
||||||
listBullet: ListBulletIcon,
|
listBullet: ListBulletIcon,
|
||||||
magicWand: MagicWandIcon,
|
magicWand: MagicWandIcon,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function WindowDragRegion({ className, ...props }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className={classnames(className, 'w-full h-md flex-shrink-0')}
|
className={classnames(className, 'w-full flex-shrink-0')}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
254
src-web/components/layouts/WorkspaceLayout.tsx
Normal file
254
src-web/components/layouts/WorkspaceLayout.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
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 { useKeyValue } from '../../hooks/useKeyValue';
|
||||||
|
import { useSidebarDisplay } from '../../hooks/useSidebarDisplay';
|
||||||
|
import { RequestPane } from '../RequestPane';
|
||||||
|
import { ResponsePane } from '../ResponsePane';
|
||||||
|
import { Sidebar } from '../Sidebar';
|
||||||
|
import { WorkspaceHeader } from '../WorkspaceHeader';
|
||||||
|
|
||||||
|
const side = { gridArea: 'side' };
|
||||||
|
const head = { gridArea: 'head' };
|
||||||
|
const rqst = { gridArea: 'rqst' };
|
||||||
|
const resp = { gridArea: 'resp' };
|
||||||
|
|
||||||
|
export function WorkspaceLayout() {
|
||||||
|
const windowSize = useWindowSize();
|
||||||
|
const vertical = windowSize.width < 800;
|
||||||
|
const styles = useMemo<CSSProperties>(
|
||||||
|
() =>
|
||||||
|
vertical
|
||||||
|
? {
|
||||||
|
gridTemplate: `
|
||||||
|
' ${head.gridArea} ${head.gridArea}' auto
|
||||||
|
' ${side.gridArea} ${rqst.gridArea}' 1fr
|
||||||
|
' ${side.gridArea} ${resp.gridArea}' 1fr
|
||||||
|
/ auto 1fr
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
gridTemplate: `
|
||||||
|
' ${head.gridArea} ${head.gridArea} ${head.gridArea}' auto
|
||||||
|
' ${side.gridArea} ${rqst.gridArea} ${resp.gridArea}' 1fr
|
||||||
|
/ auto 1fr auto
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
[vertical],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid w-full h-full" style={styles}>
|
||||||
|
<SidebarContainer style={side} floating={windowSize.width < 800}>
|
||||||
|
<Sidebar />
|
||||||
|
</SidebarContainer>
|
||||||
|
<HeaderContainer>
|
||||||
|
<WorkspaceHeader className="pointer-events-none" />
|
||||||
|
</HeaderContainer>
|
||||||
|
<RequestContainer>
|
||||||
|
<RequestPane fullHeight />
|
||||||
|
</RequestContainer>
|
||||||
|
<ResponseContainer>
|
||||||
|
<ResponsePane />
|
||||||
|
</ResponseContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const HeaderContainer = memo(function HeaderContainer({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
className="h-9 px-3 w-full pl-20 bg-gray-50 border-b border-b-highlight text-gray-900 pt-[1px]"
|
||||||
|
style={head}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const RequestContainer = memo(function RequestContainer({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full" style={rqst}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ResponseContainer = memo(function ResponseContainer({ children }: { children: ReactNode }) {
|
||||||
|
const displayKv = useKeyValue<number>({ key: 'response_width', defaultValue: 400 });
|
||||||
|
const [isResizing, setIsResizing] = useState<boolean>(false);
|
||||||
|
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const unsub = () => {
|
||||||
|
if (moveState.current !== null) {
|
||||||
|
document.documentElement.removeEventListener('mousemove', moveState.current.move);
|
||||||
|
document.documentElement.removeEventListener('mouseup', moveState.current.up);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => displayKv.set(500), []);
|
||||||
|
|
||||||
|
const handleResizeStart = useCallback(
|
||||||
|
(e: ReactMouseEvent<HTMLDivElement>) => {
|
||||||
|
if (displayKv.value === undefined) return;
|
||||||
|
|
||||||
|
unsub();
|
||||||
|
const mouseStartX = e.clientX;
|
||||||
|
const startWidth = displayKv.value;
|
||||||
|
moveState.current = {
|
||||||
|
move: (e: MouseEvent) => {
|
||||||
|
e.preventDefault(); // Prevent text selection and things
|
||||||
|
displayKv.set(startWidth - (e.clientX - mouseStartX));
|
||||||
|
},
|
||||||
|
up: (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
unsub();
|
||||||
|
setIsResizing(false);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
document.documentElement.addEventListener('mousemove', moveState.current.move);
|
||||||
|
document.documentElement.addEventListener('mouseup', moveState.current.up);
|
||||||
|
setIsResizing(true);
|
||||||
|
},
|
||||||
|
[displayKv.value],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sidebarStyles = useMemo(
|
||||||
|
() => ({
|
||||||
|
width: displayKv.value, // No width when hidden
|
||||||
|
}),
|
||||||
|
[displayKv.value],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" style={sidebarStyles}>
|
||||||
|
<ResizeBar
|
||||||
|
isResizing={isResizing}
|
||||||
|
onResizeStart={handleResizeStart}
|
||||||
|
side="left"
|
||||||
|
onReset={handleReset}
|
||||||
|
/>
|
||||||
|
{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,
|
||||||
|
);
|
||||||
|
const unsub = () => {
|
||||||
|
if (moveState.current !== null) {
|
||||||
|
document.documentElement.removeEventListener('mousemove', moveState.current.move);
|
||||||
|
document.documentElement.removeEventListener('mouseup', moveState.current.up);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResizeStart = useCallback(
|
||||||
|
(e: ReactMouseEvent<HTMLDivElement>) => {
|
||||||
|
if (sidebar.width === undefined) return;
|
||||||
|
|
||||||
|
unsub();
|
||||||
|
const mouseStartX = e.clientX;
|
||||||
|
const startWidth = sidebar.width;
|
||||||
|
moveState.current = {
|
||||||
|
move: (e: MouseEvent) => {
|
||||||
|
e.preventDefault(); // Prevent text selection and things
|
||||||
|
sidebar.set(startWidth + (e.clientX - mouseStartX));
|
||||||
|
},
|
||||||
|
up: (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
unsub();
|
||||||
|
setIsResizing(false);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
document.documentElement.addEventListener('mousemove', moveState.current.move);
|
||||||
|
document.documentElement.addEventListener('mouseup', moveState.current.up);
|
||||||
|
setIsResizing(true);
|
||||||
|
},
|
||||||
|
[sidebar.width],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sidebarStyles = useMemo(
|
||||||
|
() => ({
|
||||||
|
...style,
|
||||||
|
width: sidebar.hidden ? 0 : sidebar.width, // No width when hidden
|
||||||
|
borderWidth: sidebar.hidden ? 0 : undefined, // No border when hidden
|
||||||
|
}),
|
||||||
|
[sidebar.width, sidebar.hidden, style],
|
||||||
|
);
|
||||||
|
|
||||||
|
const commonClassname = classnames('overflow-hidden bg-gray-100 border-highlight');
|
||||||
|
|
||||||
|
if (floating) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={sidebarStyles}
|
||||||
|
className={classnames(
|
||||||
|
commonClassname,
|
||||||
|
'fixed top-10 z-10 left-1 bottom-1 border rounded-md',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classnames(commonClassname, 'relative h-full border-r')} style={sidebarStyles}>
|
||||||
|
<ResizeBar
|
||||||
|
side="right"
|
||||||
|
isResizing={isResizing}
|
||||||
|
onResizeStart={handleResizeStart}
|
||||||
|
onReset={sidebar.reset}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ResizeBarProps {
|
||||||
|
isResizing: boolean;
|
||||||
|
onResizeStart: (e: ReactMouseEvent<HTMLDivElement>) => void;
|
||||||
|
onReset?: () => void;
|
||||||
|
side: 'left' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResizeBar({ onResizeStart, onReset, isResizing, side }: ResizeBarProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
draggable
|
||||||
|
className={classnames(
|
||||||
|
'group absolute z-10 w-3 top-0 bottom-0 flex justify-end cursor-ew-resize',
|
||||||
|
side === 'right' ? '-right-0.5' : '-left-0.5',
|
||||||
|
)}
|
||||||
|
onDragStart={onResizeStart}
|
||||||
|
onDoubleClick={onReset}
|
||||||
|
>
|
||||||
|
{/* Show global overlay with cursor style to ensure cursor remains the same when moving quickly */}
|
||||||
|
{isResizing && <div className="fixed inset-0 cursor-ew-resize" />}
|
||||||
|
<div // drag-divider
|
||||||
|
className={classnames(
|
||||||
|
'transition-colors w-1 mr-0.5 group-hover:bg-highlight h-full pointer-events-none',
|
||||||
|
isResizing && '!bg-blue-500/70',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src-web/hooks/useSidebarDisplay.ts
Normal file
27
src-web/hooks/useSidebarDisplay.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { clamp } from '../lib/clamp';
|
||||||
|
import { useKeyValue } from './useKeyValue';
|
||||||
|
|
||||||
|
const START_WIDTH = 200;
|
||||||
|
const MIN_WIDTH = 110;
|
||||||
|
const MAX_WIDTH = 500;
|
||||||
|
|
||||||
|
export enum SidebarDisplayKeys {
|
||||||
|
width = 'sidebar_width',
|
||||||
|
hidden = 'sidebar_hidden',
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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]);
|
||||||
|
|
||||||
|
return { width, hidden, set, reset, hide, show, toggle };
|
||||||
|
}
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { useCallback } from 'react';
|
|
||||||
import { clamp } from '../lib/clamp';
|
|
||||||
import { useKeyValue } from './useKeyValue';
|
|
||||||
|
|
||||||
const INITIAL_WIDTH = 200;
|
|
||||||
|
|
||||||
const MIN_WIDTH = 110;
|
|
||||||
const MAX_WIDTH = 500;
|
|
||||||
|
|
||||||
export function useSidebarWidth() {
|
|
||||||
const width = useKeyValue<number>({ key: 'sidebar_width', defaultValue: INITIAL_WIDTH });
|
|
||||||
|
|
||||||
const setWidth = useCallback((v: number) => {
|
|
||||||
width.set(clamp(v, MIN_WIDTH, MAX_WIDTH));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resetWidth = useCallback(() => {
|
|
||||||
width.set(INITIAL_WIDTH);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { value: width.value ?? INITIAL_WIDTH, set: setWidth, reset: resetWidth };
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user