diff --git a/src-web/components/EnvironmentActionsDropdown.tsx b/src-web/components/EnvironmentActionsDropdown.tsx index 91d2c51f..67338fd7 100644 --- a/src-web/components/EnvironmentActionsDropdown.tsx +++ b/src-web/components/EnvironmentActionsDropdown.tsx @@ -33,7 +33,7 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo }); }, [dialog, activeEnvironment]); - useHotkey('environmentEditor.show', showEnvironmentDialog); + useHotkey('environmentEditor.toggle', showEnvironmentDialog); const items: DropdownItem[] = useMemo( () => [ @@ -58,6 +58,7 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo { key: 'edit', label: 'Manage Environments', + hotkeyAction: 'environmentEditor.toggle', leftSlot: , onSelect: showEnvironmentDialog, }, diff --git a/src-web/components/EnvironmentEditDialog.tsx b/src-web/components/EnvironmentEditDialog.tsx index 13b0ceb2..d6625c4d 100644 --- a/src-web/components/EnvironmentEditDialog.tsx +++ b/src-web/components/EnvironmentEditDialog.tsx @@ -43,6 +43,11 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) { [environments, selectedEnvironmentId], ); + const handleCreateEnvironment = async () => { + const e = await createEnvironment.mutateAsync(); + setSelectedEnvironmentId(e.id); + }; + return (
createEnvironment.mutate()} + onClick={handleCreateEnvironment} > New Environment @@ -191,15 +196,24 @@ const EnvironmentEditor = function ({

{environment?.name ?? 'Base Environment'}

{items != null && ( - + )} + {environment == null && ( + + Base variables available at all times + + )} (null); - - useListenToTauriEvent('toggle_settings', () => { - dropdownRef.current?.toggle(); - }); - - // TODO: Put this somewhere better - useListenToTauriEvent('duplicate_request', () => { - duplicateRequest.mutate(); - }); - - if (requestId == null) { - return null; - } - - return ( - , - rightSlot: , - }, - { - key: 'delete', - label: 'Delete', - onSelect: deleteRequest.mutate, - variant: 'danger', - leftSlot: , - }, - ]} - > - {children} - - ); -} diff --git a/src-web/components/SettingsDropdown.tsx b/src-web/components/SettingsDropdown.tsx new file mode 100644 index 00000000..724db2e1 --- /dev/null +++ b/src-web/components/SettingsDropdown.tsx @@ -0,0 +1,69 @@ +import { invoke } from '@tauri-apps/api'; +import { useRef } from 'react'; +import { useAppVersion } from '../hooks/useAppVersion'; +import { useExportData } from '../hooks/useExportData'; +import { useImportData } from '../hooks/useImportData'; +import { useTheme } from '../hooks/useTheme'; +import { useUpdateMode } from '../hooks/useUpdateMode'; +import type { DropdownProps, DropdownRef } from './core/Dropdown'; +import { Dropdown } from './core/Dropdown'; +import { Icon } from './core/Icon'; + +interface Props { + requestId: string | null; + children: DropdownProps['children']; +} + +export function SettingsDropdown({ requestId, children }: Props) { + const importData = useImportData(); + const exportData = useExportData(); + const { appearance, toggleAppearance } = useTheme(); + const appVersion = useAppVersion(); + const [updateMode, setUpdateMode] = useUpdateMode(); + const dropdownRef = useRef(null); + + if (requestId == null) { + return null; + } + + return ( + , + onSelect: () => importData.mutate(), + }, + { + key: 'export-data', + label: 'Export', + leftSlot: , + onSelect: () => exportData.mutate(), + }, + { + key: 'appearance', + label: 'Toggle Theme', + onSelect: toggleAppearance, + leftSlot: , + }, + { type: 'separator', label: `v${appVersion.data}` }, + { + key: 'update-mode', + label: updateMode === 'stable' ? 'Enable Beta' : 'Disable Beta', + onSelect: () => setUpdateMode(updateMode === 'stable' ? 'beta' : 'stable'), + leftSlot: , + }, + { + key: 'update-check', + label: 'Check for Updates', + onSelect: () => invoke('check_for_updates'), + leftSlot: , + }, + ]} + > + {children} + + ); +} diff --git a/src-web/components/Sidebar.tsx b/src-web/components/Sidebar.tsx index aa7b0d38..cf2ab151 100644 --- a/src-web/components/Sidebar.tsx +++ b/src-web/components/Sidebar.tsx @@ -13,6 +13,8 @@ import { useCreateFolder } from '../hooks/useCreateFolder'; import { useCreateRequest } from '../hooks/useCreateRequest'; import { useDeleteAnyRequest } from '../hooks/useDeleteAnyRequest'; import { useDeleteFolder } from '../hooks/useDeleteFolder'; +import { useDeleteRequest } from '../hooks/useDeleteRequest'; +import { useDuplicateRequest } from '../hooks/useDuplicateRequest'; import { useFolders } from '../hooks/useFolders'; import { useHotkey } from '../hooks/useHotkey'; import { useKeyValue } from '../hooks/useKeyValue'; @@ -28,9 +30,8 @@ import { fallbackRequestName } from '../lib/fallbackRequestName'; import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore'; import type { Folder, HttpRequest, Workspace } from '../lib/models'; import { isResponseLoading } from '../lib/models'; -import { Dropdown } from './core/Dropdown'; +import { ContextMenu } from './core/Dropdown'; import { Icon } from './core/Icon'; -import { IconButton } from './core/IconButton'; import { InlineCode } from './core/InlineCode'; import { VStack } from './core/Stacks'; import { StatusTag } from './core/StatusTag'; @@ -82,10 +83,18 @@ export function Sidebar({ className }: Props) { const { tree, treeParentMap, selectableRequests } = useMemo<{ tree: TreeNode | null; treeParentMap: Record; - selectableRequests: { id: string; index: number; tree: TreeNode }[]; + selectableRequests: { + id: string; + index: number; + tree: TreeNode; + }[]; }>(() => { const treeParentMap: Record = {}; - const selectableRequests: { id: string; index: number; tree: TreeNode }[] = []; + const selectableRequests: { + id: string; + index: number; + tree: TreeNode; + }[] = []; if (activeWorkspace == null) { return { tree: null, treeParentMap, selectableRequests }; } @@ -116,7 +125,15 @@ export function Sidebar({ className }: Props) { }, [activeWorkspace, requests, folders]); const focusActiveRequest = useCallback( - (args: { forced?: { id: string; tree: TreeNode }; noFocusSidebar?: boolean } = {}) => { + ( + args: { + forced?: { + id: string; + tree: TreeNode; + }; + noFocusSidebar?: boolean; + } = {}, + ) => { const { forced, noFocusSidebar } = args; const tree = forced?.tree ?? treeParentMap[activeRequestId ?? 'n/a'] ?? null; const children = tree?.children ?? []; @@ -502,6 +519,8 @@ const SidebarItem = forwardRef(function SidebarItem( const createRequest = useCreateRequest(); const createFolder = useCreateFolder(); const deleteFolder = useDeleteFolder(itemId); + const deleteRequest = useDeleteRequest(itemId); + const duplicateRequest = useDuplicateRequest({ id: itemId, navigateAfter: true }); const sendManyRequests = useSendManyRequests(); const latestResponse = useLatestResponse(itemId); const updateRequest = useUpdateRequest(itemId); @@ -554,74 +573,99 @@ const SidebarItem = forwardRef(function SidebarItem( ); const handleSelect = useCallback(() => onSelect(itemId), [onSelect, itemId]); + const [showContextMenu, setShowContextMenu] = useState<{ + x: number; + y: number; + } | null>(null); + + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + console.log('CONTEXT MENU'); + setShowContextMenu({ x: e.clientX, y: e.clientY }); + }, []); return (
  • - {itemModel === 'folder' && ( - , - onSelect: () => sendManyRequests.mutate(child.children.map((c) => c.item.id)), - }, - { type: 'separator', label: itemName }, - { - key: 'rename', - label: 'Rename', - leftSlot: , - onSelect: async () => { - const name = await prompt({ - title: 'Rename Folder', - description: ( - <> - Enter a new name for {itemName} - - ), - name: 'name', - label: 'Name', - defaultValue: itemName, - }); - updateAnyFolder.mutate({ id: itemId, update: (f) => ({ ...f, name }) }); - }, - }, - { - key: 'deleteFolder', - label: 'Delete', - variant: 'danger', - leftSlot: , - onSelect: () => deleteFolder.mutate(), - }, - { type: 'separator' }, - { - key: 'createRequest', - label: 'New Request', - leftSlot: , - onSelect: () => createRequest.mutate({ folderId: itemId, sortPriority: -1 }), - }, - { - key: 'createFolder', - label: 'New Folder', - leftSlot: , - onSelect: () => createFolder.mutate({ folderId: itemId, sortPriority: -1 }), - }, - ]} - > - - - )} + , + onSelect: () => sendManyRequests.mutate(child.children.map((c) => c.item.id)), + }, + { type: 'separator', label: itemName }, + { + key: 'rename', + label: 'Rename', + leftSlot: , + onSelect: async () => { + const name = await prompt({ + title: 'Rename Folder', + description: ( + <> + Enter a new name for {itemName} + + ), + name: 'name', + label: 'Name', + defaultValue: itemName, + }); + updateAnyFolder.mutate({ id: itemId, update: (f) => ({ ...f, name }) }); + }, + }, + { + key: 'deleteFolder', + label: 'Delete', + variant: 'danger', + leftSlot: , + onSelect: () => deleteFolder.mutate(), + }, + { type: 'separator' }, + { + key: 'createRequest', + label: 'New Request', + hotkeyAction: 'request.create', + leftSlot: , + onSelect: () => createRequest.mutate({ folderId: itemId, sortPriority: -1 }), + }, + { + key: 'createFolder', + label: 'New Folder', + leftSlot: , + onSelect: () => createFolder.mutate({ folderId: itemId, sortPriority: -1 }), + }, + ] + : [ + { + key: 'duplicateRequest', + label: 'Duplicate', + hotkeyAction: 'request.duplicate', + leftSlot: , + onSelect: () => duplicateRequest.mutate(), + }, + { + key: 'deleteRequest', + variant: 'danger', + label: 'Delete', + leftSlot: , + onSelect: () => deleteRequest.mutate(), + }, + ] + } + onClose={() => setShowContextMenu(null)} + />
    - + - +
    ); diff --git a/src-web/components/core/Button.tsx b/src-web/components/core/Button.tsx index b859dd18..de9c9b1d 100644 --- a/src-web/components/core/Button.tsx +++ b/src-web/components/core/Button.tsx @@ -2,7 +2,7 @@ import classNames from 'classnames'; import type { HTMLAttributes, ReactNode } from 'react'; import { forwardRef, memo, useImperativeHandle, useMemo, useRef } from 'react'; import type { HotkeyAction } from '../../hooks/useHotkey'; -import { useHotkey } from '../../hooks/useHotkey'; +import { useFormattedHotkey, useHotkey } from '../../hooks/useHotkey'; import { Icon } from './Icon'; const colorStyles = { @@ -47,11 +47,15 @@ const _Button = forwardRef(function Button( rightSlot, disabled, hotkeyAction, + title, onClick, ...props }: ButtonProps, ref, ) { + const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null); + const fullTitle = hotkeyTrigger ? `${title} ${hotkeyTrigger}` : title; + const classes = useMemo( () => classNames( @@ -88,6 +92,7 @@ const _Button = forwardRef(function Button( className={classes} disabled={disabled} onClick={onClick} + title={fullTitle} {...props} > {isLoading ? ( diff --git a/src-web/components/core/Dropdown.tsx b/src-web/components/core/Dropdown.tsx index e56d6bea..954ea894 100644 --- a/src-web/components/core/Dropdown.tsx +++ b/src-web/components/core/Dropdown.tsx @@ -20,8 +20,10 @@ import React, { useState, } from 'react'; import { useKey, useKeyPressEvent, useWindowSize } from 'react-use'; +import type { HotkeyAction } from '../../hooks/useHotkey'; import { Overlay } from '../Overlay'; import { Button } from './Button'; +import { HotKey } from './HotKey'; import { Separator } from './Separator'; import { VStack } from './Stacks'; @@ -30,19 +32,20 @@ export type DropdownItemSeparator = { label?: string; }; -export type DropdownItem = - | { - key: string; - type?: 'default'; - label: ReactNode; - variant?: 'danger'; - disabled?: boolean; - hidden?: boolean; - leftSlot?: ReactNode; - rightSlot?: ReactNode; - onSelect?: () => void; - } - | DropdownItemSeparator; +export type DropdownItemDefault = { + key: string; + type?: 'default'; + label: ReactNode; + hotkeyAction?: HotkeyAction; + variant?: 'danger'; + disabled?: boolean; + hidden?: boolean; + leftSlot?: ReactNode; + rightSlot?: ReactNode; + onSelect?: () => void; +}; + +export type DropdownItem = DropdownItemDefault | DropdownItemSeparator; export interface DropdownProps { children: ReactElement>; @@ -126,9 +129,10 @@ export const Dropdown = forwardRef(function Dropdown {open && triggerRect && ( )} @@ -136,16 +140,53 @@ export const Dropdown = forwardRef(function Dropdown ); }); +interface ContextMenuProps { + show: { x: number; y: number } | null; + className?: string; + items: DropdownProps['items']; + onClose: () => void; +} + +export const ContextMenu = forwardRef(function ContextMenu( + { show, className, items, onClose }, + ref, +) { + const triggerShape = useMemo( + () => ({ + top: show?.y ?? 0, + bottom: show?.y ?? 0, + left: show?.x ?? 0, + right: show?.x ?? 0, + }), + [show], + ); + + if (show === null) { + return null; + } + + return ( + + ); +}); + interface MenuProps { className?: string; defaultSelectedIndex?: number; items: DropdownProps['items']; - triggerRect: DOMRect; + triggerShape: Pick; onClose: () => void; + showTriangle?: boolean; } const Menu = forwardRef, MenuProps>(function Menu( - { className, items, onClose, triggerRect, defaultSelectedIndex }: MenuProps, + { className, items, onClose, triggerShape, defaultSelectedIndex, showTriangle }: MenuProps, ref, ) { const containerRef = useRef(null); @@ -248,21 +289,27 @@ const Menu = forwardRef, MenuPro const { containerStyles, triangleStyles } = useMemo<{ containerStyles: CSSProperties; - triangleStyles: CSSProperties; + triangleStyles: CSSProperties | null; }>(() => { - const docWidth = document.documentElement.getBoundingClientRect().width; - const spaceRemaining = docWidth - triggerRect.left; - const top = triggerRect?.bottom + 5; - const onRight = spaceRemaining < 200; - const containerStyles = onRight - ? { top, right: docWidth - triggerRect?.right } - : { top, left: triggerRect?.left }; + const docRect = document.documentElement.getBoundingClientRect(); + const width = triggerShape.right - triggerShape.left; + const hSpaceRemaining = docRect.width - triggerShape.left; + const vSpaceRemaining = docRect.height - triggerShape.bottom; + const top = triggerShape?.bottom + 5; + const onRight = hSpaceRemaining < 200; + const upsideDown = vSpaceRemaining < 200; + const containerStyles = { + top: !upsideDown ? top : undefined, + bottom: upsideDown ? top : undefined, + right: onRight ? docRect.width - triggerShape?.right : undefined, + left: !onRight ? triggerShape?.left : undefined, + }; const size = { top: '-0.2rem', width: '0.4rem', height: '0.4rem' }; const triangleStyles = onRight - ? { right: triggerRect.width / 2, marginRight: '-0.2rem', ...size } - : { left: triggerRect.width / 2, marginLeft: '-0.2rem', ...size }; + ? { right: width / 2, marginRight: '-0.2rem', ...size } + : { left: width / 2, marginLeft: '-0.2rem', ...size }; return { containerStyles, triangleStyles }; - }, [triggerRect]); + }, [triggerShape]); const handleFocus = useCallback( (i: DropdownItem) => { @@ -290,11 +337,13 @@ const Menu = forwardRef, MenuPro style={containerStyles} className={classNames(className, 'outline-none mt-1 pointer-events-auto fixed z-50')} > - + {triangleStyles && showTriangle && ( + + )} {containerStyles && ( , MenuPro interface MenuItemProps { className?: string; - item: DropdownItem; - onSelect: (item: DropdownItem) => void; - onFocus: (item: DropdownItem) => void; + item: DropdownItemDefault; + onSelect: (item: DropdownItemDefault) => void; + onFocus: (item: DropdownItemDefault) => void; focused: boolean; } @@ -359,7 +408,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men [focused], ); - if (item.type === 'separator') return ; + const rightSlot = item.rightSlot ?? ; return (
  • } - rightSlot={item.rightSlot &&
    {item.rightSlot}
    } + rightSlot={rightSlot &&
    {rightSlot}
    } className={classNames( className, 'min-w-[8rem] outline-none px-2 mx-1.5 flex text-sm text-gray-700 whitespace-nowrap', diff --git a/src-web/components/core/HotKey.tsx b/src-web/components/core/HotKey.tsx index e6c3f5cd..6802e1e6 100644 --- a/src-web/components/core/HotKey.tsx +++ b/src-web/components/core/HotKey.tsx @@ -1,21 +1,20 @@ import classNames from 'classnames'; +import type { HotkeyAction } from '../../hooks/useHotkey'; +import { useFormattedHotkey } from '../../hooks/useHotkey'; +import { useOsInfo } from '../../hooks/useOsInfo'; interface Props { - modifier: 'Meta' | 'Control' | 'Shift'; - keyName: string; + action: HotkeyAction | null; } -const keys: Record = { - Control: '⌃', - Meta: '⌘', - Shift: '⇧', -}; +export function HotKey({ action }: Props) { + const osinfo = useOsInfo(); + const label = useFormattedHotkey(action); + if (label === null || osinfo == null) { + return null; + } -export function HotKey({ modifier, keyName }: Props) { return ( - - {keys[modifier]} - {keyName} - + {label} ); } diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index 3fac0563..ce43843c 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -1,44 +1,4 @@ -import { - ArchiveIcon, - CameraIcon, - CheckboxIcon, - CheckIcon, - ChevronDownIcon, - ChevronRightIcon, - ClockIcon, - CodeIcon, - ColorWheelIcon, - CopyIcon, - Cross2Icon, - DividerHorizontalIcon, - DotsHorizontalIcon, - DotsVerticalIcon, - DownloadIcon, - DragHandleDots2Icon, - EyeClosedIcon, - EyeOpenIcon, - GearIcon, - HamburgerMenuIcon, - HomeIcon, - ListBulletIcon, - MagicWandIcon, - MagnifyingGlassIcon, - MoonIcon, - OpenInNewWindowIcon, - PaperPlaneIcon, - Pencil2Icon, - PlusCircledIcon, - PlusIcon, - QuestionMarkIcon, - RowsIcon, - SunIcon, - TrashIcon, - TriangleDownIcon, - TriangleLeftIcon, - TriangleRightIcon, - UpdateIcon, - UploadIcon, -} from '@radix-ui/react-icons'; +import * as ReactIcons from '@radix-ui/react-icons'; import classNames from 'classnames'; import type { HTMLAttributes } from 'react'; import { memo } from 'react'; @@ -46,47 +6,49 @@ import { ReactComponent as LeftPanelHiddenIcon } from '../../assets/icons/LeftPa import { ReactComponent as LeftPanelVisibleIcon } from '../../assets/icons/LeftPanelVisibleIcon.svg'; const icons = { - archive: ArchiveIcon, - camera: CameraIcon, - check: CheckIcon, - checkbox: CheckboxIcon, - clock: ClockIcon, - chevronDown: ChevronDownIcon, - chevronRight: ChevronRightIcon, - code: CodeIcon, - colorWheel: ColorWheelIcon, - copy: CopyIcon, - dividerH: DividerHorizontalIcon, - dotsH: DotsHorizontalIcon, - dotsV: DotsVerticalIcon, - download: DownloadIcon, - drag: DragHandleDots2Icon, - eye: EyeOpenIcon, - eyeClosed: EyeClosedIcon, - gear: GearIcon, - hamburger: HamburgerMenuIcon, - home: HomeIcon, + archive: ReactIcons.ArchiveIcon, + camera: ReactIcons.CameraIcon, + check: ReactIcons.CheckIcon, + checkbox: ReactIcons.CheckboxIcon, + clock: ReactIcons.ClockIcon, + chevronDown: ReactIcons.ChevronDownIcon, + chevronRight: ReactIcons.ChevronRightIcon, + code: ReactIcons.CodeIcon, + colorWheel: ReactIcons.ColorWheelIcon, + copy: ReactIcons.CopyIcon, + dividerH: ReactIcons.DividerHorizontalIcon, + dotsH: ReactIcons.DotsHorizontalIcon, + dotsV: ReactIcons.DotsVerticalIcon, + download: ReactIcons.DownloadIcon, + drag: ReactIcons.DragHandleDots2Icon, + eye: ReactIcons.EyeOpenIcon, + eyeClosed: ReactIcons.EyeClosedIcon, + gear: ReactIcons.GearIcon, + hamburger: ReactIcons.HamburgerMenuIcon, + home: ReactIcons.HomeIcon, + listBullet: ReactIcons.ListBulletIcon, + magicWand: ReactIcons.MagicWandIcon, + magnifyingGlass: ReactIcons.MagnifyingGlassIcon, + moon: ReactIcons.MoonIcon, + openNewWindow: ReactIcons.OpenInNewWindowIcon, + paperPlane: ReactIcons.PaperPlaneIcon, + pencil: ReactIcons.Pencil2Icon, + plus: ReactIcons.PlusIcon, + plusCircle: ReactIcons.PlusCircledIcon, + question: ReactIcons.QuestionMarkIcon, + rows: ReactIcons.RowsIcon, + sun: ReactIcons.SunIcon, + trash: ReactIcons.TrashIcon, + triangleDown: ReactIcons.TriangleDownIcon, + triangleLeft: ReactIcons.TriangleLeftIcon, + triangleRight: ReactIcons.TriangleRightIcon, + update: ReactIcons.UpdateIcon, + upload: ReactIcons.UploadIcon, + x: ReactIcons.Cross2Icon, + + // Custom leftPanelHidden: LeftPanelHiddenIcon, leftPanelVisible: LeftPanelVisibleIcon, - listBullet: ListBulletIcon, - magicWand: MagicWandIcon, - magnifyingGlass: MagnifyingGlassIcon, - moon: MoonIcon, - openNewWindow: OpenInNewWindowIcon, - paperPlane: PaperPlaneIcon, - pencil: Pencil2Icon, - plus: PlusIcon, - plusCircle: PlusCircledIcon, - question: QuestionMarkIcon, - rows: RowsIcon, - sun: SunIcon, - trash: TrashIcon, - triangleDown: TriangleDownIcon, - triangleLeft: TriangleLeftIcon, - triangleRight: TriangleRightIcon, - update: UpdateIcon, - upload: UploadIcon, - x: Cross2Icon, empty: (props: HTMLAttributes) => , }; diff --git a/src-web/components/core/IconButton.tsx b/src-web/components/core/IconButton.tsx index 542dcd52..076ee9ff 100644 --- a/src-web/components/core/IconButton.tsx +++ b/src-web/components/core/IconButton.tsx @@ -38,6 +38,7 @@ export const IconButton = forwardRef(function IconButt }, [onClick, setConfirmed, showConfirm], ); + return (