diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2e095bd0..d8806ca4 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -634,6 +634,10 @@ fn create_window(handle: &AppHandle) -> Window { CustomMenuItem::new("duplicate_request".to_string(), "Duplicate Request") .accelerator("CmdOrCtrl+d"), ) + .add_item( + CustomMenuItem::new("focus_sidebar".to_string(), "Focus Sidebar") + .accelerator("CmdOrCtrl+1"), + ) .add_item(CustomMenuItem::new("new_window".to_string(), "New Window")); if is_dev() { test_menu = test_menu @@ -677,6 +681,7 @@ fn create_window(handle: &AppHandle) -> Window { "zoom_out" => win2.emit("zoom", -1).unwrap(), "toggle_sidebar" => win2.emit("toggle_sidebar", true).unwrap(), "focus_url" => win2.emit("focus_url", true).unwrap(), + "focus_sidebar" => win2.emit("focus_sidebar", true).unwrap(), "send_request" => win2.emit("send_request", true).unwrap(), "new_request" => _ = win2.emit("new_request", true).unwrap(), "duplicate_request" => _ = win2.emit("duplicate_request", true).unwrap(), diff --git a/src-web/components/RecentRequestsDropdown.tsx b/src-web/components/RecentRequestsDropdown.tsx index b0544d4d..ab958462 100644 --- a/src-web/components/RecentRequestsDropdown.tsx +++ b/src-web/components/RecentRequestsDropdown.tsx @@ -48,7 +48,7 @@ export function RecentRequestsDropdown() { recentRequestItems.push({ label: request.name, - leftSlot: , + leftSlot: , onSelect: () => { routes.navigate('request', { requestId: request.id, diff --git a/src-web/components/Sidebar.tsx b/src-web/components/Sidebar.tsx index d8f86849..3bd9360e 100644 --- a/src-web/components/Sidebar.tsx +++ b/src-web/components/Sidebar.tsx @@ -3,11 +3,14 @@ import type { ForwardedRef, KeyboardEvent } from 'react'; import React, { forwardRef, Fragment, memo, useCallback, useMemo, useRef, useState } from 'react'; import type { XYCoord } from 'react-dnd'; import { useDrag, useDrop } from 'react-dnd'; -import { NavLink } from 'react-router-dom'; +import { useKeyPressEvent } from 'react-use'; import { useActiveRequestId } from '../hooks/useActiveRequestId'; -import { useDeleteRequest } from '../hooks/useDeleteRequest'; +import { useAppRoutes } from '../hooks/useAppRoutes'; +import { useDeleteAnyRequest } from '../hooks/useDeleteAnyRequest'; import { useLatestResponse } from '../hooks/useLatestResponse'; import { useRequests } from '../hooks/useRequests'; +import { useSidebarHidden } from '../hooks/useSidebarHidden'; +import { useTauriEvent } from '../hooks/useTauriEvent'; import { useUpdateAnyRequest } from '../hooks/useUpdateAnyRequest'; import { useUpdateRequest } from '../hooks/useUpdateRequest'; import type { HttpRequest } from '../lib/models'; @@ -26,17 +29,96 @@ enum ItemTypes { } export const Sidebar = memo(function Sidebar({ className }: Props) { + const { hidden } = useSidebarHidden(); const sidebarRef = useRef(null); + const activeRequestId = useActiveRequestId(); const unorderedRequests = useRequests(); + const deleteAnyRequest = useDeleteAnyRequest(); + const routes = useAppRoutes(); const requests = useMemo( () => [...unorderedRequests].sort((a, b) => a.sortPriority - b.sortPriority), [unorderedRequests], ); + const [hasFocus, setHasFocus] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(); + + const focusActiveRequest = useCallback( + (forcedIndex?: number) => { + const index = forcedIndex ?? requests.findIndex((r) => r.id === activeRequestId); + setSelectedIndex(index >= 0 ? index : 0); + sidebarRef.current?.focus(); + }, + [activeRequestId, requests], + ); + + const handleSelect = useCallback( + (requestId: string) => { + const index = requests.findIndex((r) => r.id === requestId); + const request = requests[index]; + if (!request || request.id === activeRequestId) return; + routes.navigate('request', { requestId, workspaceId: request.workspaceId }); + setSelectedIndex(index); + focusActiveRequest(index); + }, + [activeRequestId, focusActiveRequest, requests, routes], + ); + + const handleFocus = useCallback(() => setHasFocus(true), []); + const handleBlur = useCallback(() => setHasFocus(false), []); + + useTauriEvent( + 'focus_sidebar', + () => { + if (hidden) return; + focusActiveRequest(); + }, + [focusActiveRequest, hidden], + ); + + useKeyPressEvent('Enter', (e) => { + if (!hasFocus) return; + const request = requests[selectedIndex ?? -1]; + if (!request || request.id === activeRequestId) return; + e.preventDefault(); + routes.navigate('request', { requestId: request.id, workspaceId: request.workspaceId }); + }); + + useKeyPressEvent('ArrowUp', () => { + if (!hasFocus) return; + let newIndex = (selectedIndex ?? requests.length) - 1; + if (newIndex < 0) { + newIndex = requests.length - 1; + } + setSelectedIndex(newIndex); + }); + + useKeyPressEvent('ArrowDown', () => { + if (!hasFocus) return; + let newIndex = (selectedIndex ?? -1) + 1; + if (newIndex > requests.length - 1) { + newIndex = 0; + } + setSelectedIndex(newIndex); + }); + + useKeyPressEvent('Backspace', (e) => { + if (!hasFocus) return; + e.preventDefault(); + const selectedRequest = requests[selectedIndex ?? -1]; + if (selectedRequest === undefined) return; + deleteAnyRequest.mutate(selectedRequest.id); + }); return ( -
+
- +
); }); -function SidebarItems({ requests }: { requests: HttpRequest[] }) { +interface SidebarItemsProps { + requests: HttpRequest[]; + focused: boolean; + selectedIndex?: number; + onSelect: (requestId: string) => void; +} + +function SidebarItems({ requests, focused, selectedIndex, onSelect }: SidebarItemsProps) { const [hoveredIndex, setHoveredIndex] = useState(null); const updateRequest = useUpdateAnyRequest(); @@ -102,11 +196,13 @@ function SidebarItems({ requests }: { requests: HttpRequest[] }) { {hoveredIndex === i && } ))} @@ -119,16 +215,17 @@ type SidebarItemProps = { className?: string; requestId: string; requestName: string; - workspaceId: string; + useProminentStyles?: boolean; + selected?: boolean; + onSelect: (requestId: string) => void; }; const _SidebarItem = forwardRef(function SidebarItem( - { className, requestName, requestId, workspaceId }: SidebarItemProps, + { className, requestName, requestId, useProminentStyles, selected, onSelect }: SidebarItemProps, ref: ForwardedRef, ) { const latestResponse = useLatestResponse(requestId); const updateRequest = useUpdateRequest(requestId); - const deleteRequest = useDeleteRequest(requestId); const [editing, setEditing] = useState(false); const activeRequestId = useActiveRequestId(); const isActive = activeRequestId === requestId; @@ -146,21 +243,6 @@ const _SidebarItem = forwardRef(function SidebarItem( el?.select(); }, []); - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - // Hitting enter on active request during keyboard nav will start edit - if (isActive && e.key === 'Enter') { - e.preventDefault(); - setEditing(true); - } - if (isActive && (e.key === 'Backspace' || e.key === 'Delete')) { - e.preventDefault(); - deleteRequest.mutate(); - } - }, - [isActive, deleteRequest], - ); - const handleInputKeyDown = useCallback( async (e: KeyboardEvent) => { e.stopPropagation(); @@ -187,48 +269,52 @@ const _SidebarItem = forwardRef(function SidebarItem( [handleSubmitNameEdit], ); + const handleSelect = useCallback(() => { + onSelect(requestId); + }, [onSelect, requestId]); + return (
  • -
    - - {editing ? ( - - ) : ( - - {requestName || 'New Request'} - - )} - {latestResponse && ( -
    - {isResponseLoading(latestResponse) ? ( - - ) : ( - - )} -
    - )} -
    -
    +
  • ); }); @@ -241,16 +327,15 @@ type DraggableSidebarItemProps = SidebarItemProps & { type DragItem = { id: string; - workspaceId: string; requestName: string; }; const DraggableSidebarItem = memo(function DraggableSidebarItem({ requestName, requestId, - workspaceId, onMove, onEnd, + ...props }: DraggableSidebarItemProps) { const ref = useRef(null); @@ -272,7 +357,7 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({ const [{ isDragging }, connectDrag] = useDrag( () => ({ type: ItemTypes.REQUEST, - item: () => ({ id: requestId, requestName, workspaceId }), + item: () => ({ id: requestId, requestName }), collect: (m) => ({ isDragging: m.isDragging() }), options: { dropEffect: 'move' }, end: () => onEnd(requestId), @@ -289,7 +374,7 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({ className={classnames(isDragging && 'opacity-20')} requestName={requestName} requestId={requestId} - workspaceId={workspaceId} + {...props} /> ); }); diff --git a/src-web/components/Workspace.tsx b/src-web/components/Workspace.tsx index 6261e697..6240143a 100644 --- a/src-web/components/Workspace.tsx +++ b/src-web/components/Workspace.tsx @@ -139,11 +139,8 @@ export default function Workspace() { ) : ( <> -
    - +
    +
    - {item.leftSlot &&
    {item.leftSlot}
    } + {item.leftSlot &&
    {item.leftSlot}
    }
    {item.label}
    {item.rightSlot &&
    {item.rightSlot}
    } diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index de1b242e..88675aac 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -36,6 +36,7 @@ import { UpdateIcon, } from '@radix-ui/react-icons'; import classnames from 'classnames'; +import type { HTMLAttributes } from 'react'; import { memo } from 'react'; import { ReactComponent as LeftPanelHiddenIcon } from '../../assets/icons/LeftPanelHiddenIcon.svg'; import { ReactComponent as LeftPanelVisibleIcon } from '../../assets/icons/LeftPanelVisibleIcon.svg'; @@ -78,7 +79,7 @@ const icons = { triangleRight: TriangleRightIcon, update: UpdateIcon, x: Cross2Icon, - empty: () => , + empty: (props: HTMLAttributes) => , }; export interface IconProps { diff --git a/src-web/hooks/useDeleteAnyRequest.tsx b/src-web/hooks/useDeleteAnyRequest.tsx new file mode 100644 index 00000000..ac538b40 --- /dev/null +++ b/src-web/hooks/useDeleteAnyRequest.tsx @@ -0,0 +1,40 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { invoke } from '@tauri-apps/api'; +import { InlineCode } from '../components/core/InlineCode'; +import type { HttpRequest } from '../lib/models'; +import { getRequest } from '../lib/store'; +import { useConfirm } from './useConfirm'; +import { requestsQueryKey } from './useRequests'; +import { responsesQueryKey } from './useResponses'; + +export function useDeleteAnyRequest() { + const queryClient = useQueryClient(); + const confirm = useConfirm(); + + return useMutation({ + mutationFn: async (id) => { + const request = await getRequest(id); + const confirmed = await confirm({ + title: 'Delete Request', + variant: 'delete', + description: ( + <> + Permanently delete {request?.name}? + + ), + }); + if (!confirmed) return null; + return invoke('delete_request', { requestId: id }); + }, + onSuccess: async (request) => { + // Was it cancelled? + if (request === null) return; + + const { workspaceId, id: requestId } = request; + queryClient.setQueryData(responsesQueryKey({ requestId }), []); // Responses were deleted + queryClient.setQueryData(requestsQueryKey({ workspaceId }), (requests) => + (requests ?? []).filter((r) => r.id !== requestId), + ); + }, + }); +}