diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 9af47bff..30c71d88 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -459,12 +459,13 @@ fn main() { .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_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() { test_menu = test_menu .add_native_item(MenuItem::Separator) - .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("refresh".to_string(), "Refresh").accelerator("CmdOrCtrl + Shift + r")) + .add_item(CustomMenuItem::new("toggle_devtools".to_string(), "Open Devtools").accelerator("CmdOrCtrl + Option + i")); } let submenu = Submenu::new("Test Menu", test_menu); @@ -490,7 +491,7 @@ fn main() { create_dir_all(dir.clone()).expect("Problem creating App directory!"); 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); tauri::async_runtime::block_on(async move { let pool = SqlitePoolOptions::new() @@ -526,6 +527,7 @@ fn main() { "zoom_reset" => event.window().emit("zoom", 0).unwrap(), "zoom_in" => 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(), "send_request" => event.window().emit("send_request", true).unwrap(), "toggle_devtools" => { diff --git a/src-web/components/App.tsx b/src-web/components/App.tsx index def7f5d8..a5e48010 100644 --- a/src-web/components/App.tsx +++ b/src-web/components/App.tsx @@ -12,9 +12,10 @@ 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 { workspacesQueryKey } from '../hooks/useWorkspaces'; 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 { AppRouter } from './AppRouter'; @@ -137,6 +138,11 @@ await listen('refresh', () => { location.reload(); }); +await listen('toggle_sidebar', async () => { + const hidden = await getKeyValue({ key: SidebarDisplayKeys.hidden, fallback: false }); + await setKeyValue({ key: SidebarDisplayKeys.hidden, value: !hidden }); +}); + await listen('zoom', ({ payload: zoomDelta }: { payload: number }) => { const fontSize = parseFloat(window.getComputedStyle(document.documentElement).fontSize); diff --git a/src-web/components/AppRouter.tsx b/src-web/components/AppRouter.tsx index 54cd48be..daed690f 100644 --- a/src-web/components/AppRouter.tsx +++ b/src-web/components/AppRouter.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense } from 'react'; import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom'; import { routePaths } from '../hooks/useRoutes'; +import { WorkspaceLayout } from './layouts/WorkspaceLayout'; const Workspaces = lazy(() => import('./Workspaces')); const Workspace = lazy(() => import('./Workspace')); @@ -21,14 +22,22 @@ const router = createBrowserRouter([ }, { path: routePaths.workspace({ workspaceId: ':workspaceId' }), - element: , + element: ( + + + + ), }, { path: routePaths.request({ workspaceId: ':workspaceId', requestId: ':requestId', }), - element: , + element: ( + + + + ), }, ], }, diff --git a/src-web/components/RequestPane.tsx b/src-web/components/RequestPane.tsx index d7bf9d16..8f89ef20 100644 --- a/src-web/components/RequestPane.tsx +++ b/src-web/components/RequestPane.tsx @@ -77,7 +77,12 @@ export function RequestPane({ fullHeight, className }: Props) { ); return ( -
+
{activeRequest && ( <> (null); const activeRequestId = useActiveRequestId(); const responses = useResponses(activeRequestId); @@ -43,7 +39,7 @@ export const ResponsePane = memo(function ResponsePane({ className }: Props) { ); return ( -
+
(false); const sidebarRef = useRef(null); const unorderedRequests = useRequests(); const activeRequest = useActiveRequest(); - const createRequest = useCreateRequest({ navigateAfter: true }); - const width = useSidebarWidth(); const requests = useMemo( () => [...unorderedRequests].sort((a, b) => a.sortPriority - b.sortPriority), [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) => { - 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 ( -
- +
- - { - const lastRequest = requests[requests.length - 1]; - await createRequest.mutate({ - name: 'Test Request', - sortPriority: (lastRequest?.sortPriority ?? 0) + 1, - }); - }} - /> - @@ -346,30 +283,3 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({ /> ); }); - -interface ResizeBarProps { - isResizing: boolean; - onResizeStart: (e: ReactMouseEvent) => void; - onReset: () => void; -} - -function ResizeBar({ onResizeStart, onReset, isResizing }: ResizeBarProps) { - return ( -
- {/* Show global overlay with cursor style to ensure cursor remains the same when moving quickly */} - {isResizing &&
} -
-
- ); -} diff --git a/src-web/components/Workspace.tsx b/src-web/components/Workspace.tsx deleted file mode 100644 index da3e300c..00000000 --- a/src-web/components/Workspace.tsx +++ /dev/null @@ -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(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 ( -
- -
- -
- -
-
- {activeRequest?.name} -
-
- - - - -
-
-
- - -
-
-
- ); -} diff --git a/src-web/components/WorkspaceHeader.tsx b/src-web/components/WorkspaceHeader.tsx new file mode 100644 index 00000000..5670bff3 --- /dev/null +++ b/src-web/components/WorkspaceHeader.tsx @@ -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 ( + + + + + +
+ {activeRequest?.name} +
+
+ + {activeRequest && ( + + + + )} +
+
+ ); +} diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index e6f88521..cec534e7 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -15,6 +15,7 @@ import { DragHandleDots2Icon, EyeOpenIcon, GearIcon, + HamburgerMenuIcon, HomeIcon, ListBulletIcon, MagicWandIcon, @@ -51,6 +52,7 @@ const icons = { drag: DragHandleDots2Icon, eye: EyeOpenIcon, gear: GearIcon, + hamburger: HamburgerMenuIcon, home: HomeIcon, listBullet: ListBulletIcon, magicWand: MagicWandIcon, diff --git a/src-web/components/core/WindowDragRegion.tsx b/src-web/components/core/WindowDragRegion.tsx index 9f0840ca..0c1f310f 100644 --- a/src-web/components/core/WindowDragRegion.tsx +++ b/src-web/components/core/WindowDragRegion.tsx @@ -10,7 +10,7 @@ export function WindowDragRegion({ className, ...props }: Props) { return (
); diff --git a/src-web/components/layouts/WorkspaceLayout.tsx b/src-web/components/layouts/WorkspaceLayout.tsx new file mode 100644 index 00000000..c4684f41 --- /dev/null +++ b/src-web/components/layouts/WorkspaceLayout.tsx @@ -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( + () => + 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 ( +
+ + + + + + + + + + + + +
+ ); +} + +const HeaderContainer = memo(function HeaderContainer({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +}); + +const RequestContainer = memo(function RequestContainer({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +}); + +const ResponseContainer = memo(function ResponseContainer({ children }: { children: ReactNode }) { + const displayKv = useKeyValue({ key: 'response_width', defaultValue: 400 }); + const [isResizing, setIsResizing] = useState(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) => { + 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 ( +
+ + {children} +
+ ); +}); + +interface SidebarContainerProps { + children: ReactNode; + style: CSSProperties; + floating?: boolean; +} + +const SidebarContainer = memo(function SidebarContainer({ + children, + style, + floating, +}: SidebarContainerProps) { + const sidebar = useSidebarDisplay(); + const [isResizing, setIsResizing] = useState(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) => { + 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 ( +
+ {children} +
+ ); + } + + return ( +
+ + {children} +
+ ); +}); + +interface ResizeBarProps { + isResizing: boolean; + onResizeStart: (e: ReactMouseEvent) => void; + onReset?: () => void; + side: 'left' | 'right'; +} + +function ResizeBar({ onResizeStart, onReset, isResizing, side }: ResizeBarProps) { + return ( +
+ {/* Show global overlay with cursor style to ensure cursor remains the same when moving quickly */} + {isResizing &&
} +
+
+ ); +} diff --git a/src-web/hooks/useSidebarDisplay.ts b/src-web/hooks/useSidebarDisplay.ts new file mode 100644 index 00000000..b65b6592 --- /dev/null +++ b/src-web/hooks/useSidebarDisplay.ts @@ -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({ key: SidebarDisplayKeys.hidden, defaultValue: false }); + const widthKv = useKeyValue({ 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 }; +} diff --git a/src-web/hooks/useSidebarWidth.ts b/src-web/hooks/useSidebarWidth.ts deleted file mode 100644 index 8fd6143a..00000000 --- a/src-web/hooks/useSidebarWidth.ts +++ /dev/null @@ -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({ 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 }; -}