Good hotkey support

This commit is contained in:
Gregory Schier
2023-11-22 09:01:48 -08:00
parent 3ced7f7c18
commit b0026aff66
16 changed files with 428 additions and 345 deletions

View File

@@ -33,7 +33,7 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
}); });
}, [dialog, activeEnvironment]); }, [dialog, activeEnvironment]);
useHotkey('environmentEditor.show', showEnvironmentDialog); useHotkey('environmentEditor.toggle', showEnvironmentDialog);
const items: DropdownItem[] = useMemo( const items: DropdownItem[] = useMemo(
() => [ () => [
@@ -58,6 +58,7 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
{ {
key: 'edit', key: 'edit',
label: 'Manage Environments', label: 'Manage Environments',
hotkeyAction: 'environmentEditor.toggle',
leftSlot: <Icon icon="gear" />, leftSlot: <Icon icon="gear" />,
onSelect: showEnvironmentDialog, onSelect: showEnvironmentDialog,
}, },

View File

@@ -43,6 +43,11 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
[environments, selectedEnvironmentId], [environments, selectedEnvironmentId],
); );
const handleCreateEnvironment = async () => {
const e = await createEnvironment.mutateAsync();
setSelectedEnvironmentId(e.id);
};
return ( return (
<div <div
className={classNames( className={classNames(
@@ -76,7 +81,7 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
className="w-full text-center" className="w-full text-center"
color="gray" color="gray"
justify="center" justify="center"
onClick={() => createEnvironment.mutate()} onClick={handleCreateEnvironment}
> >
New Environment New Environment
</Button> </Button>
@@ -191,15 +196,24 @@ const EnvironmentEditor = function ({
<h1 className="text-xl">{environment?.name ?? 'Base Environment'}</h1> <h1 className="text-xl">{environment?.name ?? 'Base Environment'}</h1>
{items != null && ( {items != null && (
<Dropdown items={items}> <Dropdown items={items}>
<IconButton icon="gear" title="Environment Actions" size="sm" className="!h-auto w-8" /> <IconButton
icon="dotsV"
title="Environment Actions"
size="sm"
className="!h-auto w-8"
/>
</Dropdown> </Dropdown>
)} )}
{environment == null && (
<span className="text-sm italic text-gray-500">
Base variables available at all times
</span>
)}
</HStack> </HStack>
<PairEditor <PairEditor
nameAutocomplete={nameAutocomplete} nameAutocomplete={nameAutocomplete}
nameAutocompleteVariables={false} nameAutocompleteVariables={false}
namePlaceholder="VAR_NAME" namePlaceholder="VAR_NAME"
valuePlaceholder="variable value"
nameValidate={validateName} nameValidate={validateName}
valueAutocompleteVariables={false} valueAutocompleteVariables={false}
forceUpdateKey={environment?.id ?? workspace?.id ?? 'n/a'} forceUpdateKey={environment?.id ?? workspace?.id ?? 'n/a'}

View File

@@ -1,56 +0,0 @@
import { useRef } from 'react';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import type { DropdownProps, DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { HotKey } from './core/HotKey';
import { Icon } from './core/Icon';
interface Props {
requestId: string | null;
children: DropdownProps['children'];
}
export function RequestActionsDropdown({ requestId, children }: Props) {
const deleteRequest = useDeleteRequest(requestId);
const duplicateRequest = useDuplicateRequest({ id: requestId, navigateAfter: true });
const dropdownRef = useRef<DropdownRef>(null);
useListenToTauriEvent('toggle_settings', () => {
dropdownRef.current?.toggle();
});
// TODO: Put this somewhere better
useListenToTauriEvent('duplicate_request', () => {
duplicateRequest.mutate();
});
if (requestId == null) {
return null;
}
return (
<Dropdown
ref={dropdownRef}
items={[
{
key: 'duplicate',
label: 'Duplicate',
onSelect: duplicateRequest.mutate,
leftSlot: <Icon icon="copy" />,
rightSlot: <HotKey modifier="Meta" keyName="D" />,
},
{
key: 'delete',
label: 'Delete',
onSelect: deleteRequest.mutate,
variant: 'danger',
leftSlot: <Icon icon="trash" />,
},
]}
>
{children}
</Dropdown>
);
}

View File

@@ -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<DropdownRef>(null);
if (requestId == null) {
return null;
}
return (
<Dropdown
ref={dropdownRef}
items={[
{
key: 'import-data',
label: 'Import',
leftSlot: <Icon icon="download" />,
onSelect: () => importData.mutate(),
},
{
key: 'export-data',
label: 'Export',
leftSlot: <Icon icon="upload" />,
onSelect: () => exportData.mutate(),
},
{
key: 'appearance',
label: 'Toggle Theme',
onSelect: toggleAppearance,
leftSlot: <Icon icon={appearance === 'dark' ? 'sun' : 'moon'} />,
},
{ type: 'separator', label: `v${appVersion.data}` },
{
key: 'update-mode',
label: updateMode === 'stable' ? 'Enable Beta' : 'Disable Beta',
onSelect: () => setUpdateMode(updateMode === 'stable' ? 'beta' : 'stable'),
leftSlot: <Icon icon="camera" />,
},
{
key: 'update-check',
label: 'Check for Updates',
onSelect: () => invoke('check_for_updates'),
leftSlot: <Icon icon="update" />,
},
]}
>
{children}
</Dropdown>
);
}

View File

@@ -13,6 +13,8 @@ import { useCreateFolder } from '../hooks/useCreateFolder';
import { useCreateRequest } from '../hooks/useCreateRequest'; import { useCreateRequest } from '../hooks/useCreateRequest';
import { useDeleteAnyRequest } from '../hooks/useDeleteAnyRequest'; import { useDeleteAnyRequest } from '../hooks/useDeleteAnyRequest';
import { useDeleteFolder } from '../hooks/useDeleteFolder'; import { useDeleteFolder } from '../hooks/useDeleteFolder';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
import { useFolders } from '../hooks/useFolders'; import { useFolders } from '../hooks/useFolders';
import { useHotkey } from '../hooks/useHotkey'; import { useHotkey } from '../hooks/useHotkey';
import { useKeyValue } from '../hooks/useKeyValue'; import { useKeyValue } from '../hooks/useKeyValue';
@@ -28,9 +30,8 @@ import { fallbackRequestName } from '../lib/fallbackRequestName';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore'; import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { Folder, HttpRequest, Workspace } from '../lib/models'; import type { Folder, HttpRequest, Workspace } from '../lib/models';
import { isResponseLoading } from '../lib/models'; import { isResponseLoading } from '../lib/models';
import { Dropdown } from './core/Dropdown'; import { ContextMenu } from './core/Dropdown';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode'; import { InlineCode } from './core/InlineCode';
import { VStack } from './core/Stacks'; import { VStack } from './core/Stacks';
import { StatusTag } from './core/StatusTag'; import { StatusTag } from './core/StatusTag';
@@ -82,10 +83,18 @@ export function Sidebar({ className }: Props) {
const { tree, treeParentMap, selectableRequests } = useMemo<{ const { tree, treeParentMap, selectableRequests } = useMemo<{
tree: TreeNode | null; tree: TreeNode | null;
treeParentMap: Record<string, TreeNode>; treeParentMap: Record<string, TreeNode>;
selectableRequests: { id: string; index: number; tree: TreeNode }[]; selectableRequests: {
id: string;
index: number;
tree: TreeNode;
}[];
}>(() => { }>(() => {
const treeParentMap: Record<string, TreeNode> = {}; const treeParentMap: Record<string, TreeNode> = {};
const selectableRequests: { id: string; index: number; tree: TreeNode }[] = []; const selectableRequests: {
id: string;
index: number;
tree: TreeNode;
}[] = [];
if (activeWorkspace == null) { if (activeWorkspace == null) {
return { tree: null, treeParentMap, selectableRequests }; return { tree: null, treeParentMap, selectableRequests };
} }
@@ -116,7 +125,15 @@ export function Sidebar({ className }: Props) {
}, [activeWorkspace, requests, folders]); }, [activeWorkspace, requests, folders]);
const focusActiveRequest = useCallback( const focusActiveRequest = useCallback(
(args: { forced?: { id: string; tree: TreeNode }; noFocusSidebar?: boolean } = {}) => { (
args: {
forced?: {
id: string;
tree: TreeNode;
};
noFocusSidebar?: boolean;
} = {},
) => {
const { forced, noFocusSidebar } = args; const { forced, noFocusSidebar } = args;
const tree = forced?.tree ?? treeParentMap[activeRequestId ?? 'n/a'] ?? null; const tree = forced?.tree ?? treeParentMap[activeRequestId ?? 'n/a'] ?? null;
const children = tree?.children ?? []; const children = tree?.children ?? [];
@@ -502,6 +519,8 @@ const SidebarItem = forwardRef(function SidebarItem(
const createRequest = useCreateRequest(); const createRequest = useCreateRequest();
const createFolder = useCreateFolder(); const createFolder = useCreateFolder();
const deleteFolder = useDeleteFolder(itemId); const deleteFolder = useDeleteFolder(itemId);
const deleteRequest = useDeleteRequest(itemId);
const duplicateRequest = useDuplicateRequest({ id: itemId, navigateAfter: true });
const sendManyRequests = useSendManyRequests(); const sendManyRequests = useSendManyRequests();
const latestResponse = useLatestResponse(itemId); const latestResponse = useLatestResponse(itemId);
const updateRequest = useUpdateRequest(itemId); const updateRequest = useUpdateRequest(itemId);
@@ -554,74 +573,99 @@ const SidebarItem = forwardRef(function SidebarItem(
); );
const handleSelect = useCallback(() => onSelect(itemId), [onSelect, itemId]); const handleSelect = useCallback(() => onSelect(itemId), [onSelect, itemId]);
const [showContextMenu, setShowContextMenu] = useState<{
x: number;
y: number;
} | null>(null);
const handleContextMenu = useCallback((e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
console.log('CONTEXT MENU');
setShowContextMenu({ x: e.clientX, y: e.clientY });
}, []);
return ( return (
<li ref={ref}> <li ref={ref}>
<div className={classNames(className, 'block relative group/item px-2 pb-0.5')}> <div className={classNames(className, 'block relative group/item px-2 pb-0.5')}>
{itemModel === 'folder' && ( <ContextMenu
<Dropdown show={showContextMenu}
items={[ items={
{ itemModel === 'folder'
key: 'sendAll', ? [
label: 'Send All', {
leftSlot: <Icon icon="paperPlane" />, key: 'sendAll',
onSelect: () => sendManyRequests.mutate(child.children.map((c) => c.item.id)), label: 'Send All',
}, leftSlot: <Icon icon="paperPlane" />,
{ type: 'separator', label: itemName }, onSelect: () => sendManyRequests.mutate(child.children.map((c) => c.item.id)),
{ },
key: 'rename', { type: 'separator', label: itemName },
label: 'Rename', {
leftSlot: <Icon icon="pencil" />, key: 'rename',
onSelect: async () => { label: 'Rename',
const name = await prompt({ leftSlot: <Icon icon="pencil" />,
title: 'Rename Folder', onSelect: async () => {
description: ( const name = await prompt({
<> title: 'Rename Folder',
Enter a new name for <InlineCode>{itemName}</InlineCode> description: (
</> <>
), Enter a new name for <InlineCode>{itemName}</InlineCode>
name: 'name', </>
label: 'Name', ),
defaultValue: itemName, name: 'name',
}); label: 'Name',
updateAnyFolder.mutate({ id: itemId, update: (f) => ({ ...f, name }) }); defaultValue: itemName,
}, });
}, updateAnyFolder.mutate({ id: itemId, update: (f) => ({ ...f, name }) });
{ },
key: 'deleteFolder', },
label: 'Delete', {
variant: 'danger', key: 'deleteFolder',
leftSlot: <Icon icon="trash" />, label: 'Delete',
onSelect: () => deleteFolder.mutate(), variant: 'danger',
}, leftSlot: <Icon icon="trash" />,
{ type: 'separator' }, onSelect: () => deleteFolder.mutate(),
{ },
key: 'createRequest', { type: 'separator' },
label: 'New Request', {
leftSlot: <Icon icon="plus" />, key: 'createRequest',
onSelect: () => createRequest.mutate({ folderId: itemId, sortPriority: -1 }), label: 'New Request',
}, hotkeyAction: 'request.create',
{ leftSlot: <Icon icon="plus" />,
key: 'createFolder', onSelect: () => createRequest.mutate({ folderId: itemId, sortPriority: -1 }),
label: 'New Folder', },
leftSlot: <Icon icon="plus" />, {
onSelect: () => createFolder.mutate({ folderId: itemId, sortPriority: -1 }), key: 'createFolder',
}, label: 'New Folder',
]} leftSlot: <Icon icon="plus" />,
> onSelect: () => createFolder.mutate({ folderId: itemId, sortPriority: -1 }),
<IconButton },
title="Folder options" ]
size="xs" : [
icon="dotsV" {
className="ml-auto !bg-transparent absolute right-2 opacity-0 group-hover/item:opacity-70 transition-opacity" key: 'duplicateRequest',
/> label: 'Duplicate',
</Dropdown> hotkeyAction: 'request.duplicate',
)} leftSlot: <Icon icon="copy" />,
onSelect: () => duplicateRequest.mutate(),
},
{
key: 'deleteRequest',
variant: 'danger',
label: 'Delete',
leftSlot: <Icon icon="trash" />,
onSelect: () => deleteRequest.mutate(),
},
]
}
onClose={() => setShowContextMenu(null)}
/>
<button <button
// tabIndex={-1} // Will prevent drag-n-drop // tabIndex={-1} // Will prevent drag-n-drop
disabled={editing} disabled={editing}
onClick={handleSelect} onClick={handleSelect}
onDoubleClick={handleStartEditing} onDoubleClick={handleStartEditing}
onContextMenu={handleContextMenu}
data-active={isActive} data-active={isActive}
data-selected={selected} data-selected={selected}
className={classNames( className={classNames(
@@ -710,7 +754,13 @@ function DraggableSidebarItem({
[onMove], [onMove],
); );
const [{ isDragging }, connectDrag] = useDrag<DragItem, unknown, { isDragging: boolean }>( const [{ isDragging }, connectDrag] = useDrag<
DragItem,
unknown,
{
isDragging: boolean;
}
>(
() => ({ () => ({
type: ItemTypes.REQUEST, type: ItemTypes.REQUEST,
item: () => { item: () => {

View File

@@ -1,16 +1,19 @@
import { memo } from 'react'; import { memo } from 'react';
import { useCreateFolder } from '../hooks/useCreateFolder'; import { useCreateFolder } from '../hooks/useCreateFolder';
import { useCreateRequest } from '../hooks/useCreateRequest'; import { useCreateRequest } from '../hooks/useCreateRequest';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useHotkey } from '../hooks/useHotkey'; import { useHotkey } from '../hooks/useHotkey';
import { usePrompt } from '../hooks/usePrompt';
import { useSidebarHidden } from '../hooks/useSidebarHidden'; import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { Dropdown } from './core/Dropdown'; import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton'; import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks'; import { HStack } from './core/Stacks';
export const SidebarActions = memo(function SidebarActions() { export const SidebarActions = memo(function SidebarActions() {
const createRequest = useCreateRequest(); const createRequest = useCreateRequest();
const createFolder = useCreateFolder(); const createFolder = useCreateFolder();
const createWorkspace = useCreateWorkspace({ navigateAfter: true });
const prompt = usePrompt();
const { hidden, toggle } = useSidebarHidden(); const { hidden, toggle } = useSidebarHidden();
useHotkey('request.create', () => createRequest.mutate({})); useHotkey('request.create', () => createRequest.mutate({}));
@@ -22,6 +25,7 @@ export const SidebarActions = memo(function SidebarActions() {
className="pointer-events-auto" className="pointer-events-auto"
size="sm" size="sm"
title="Show sidebar" title="Show sidebar"
hotkeyAction="sidebar.toggle"
icon={hidden ? 'leftPanelHidden' : 'leftPanelVisible'} icon={hidden ? 'leftPanelHidden' : 'leftPanelVisible'}
/> />
<Dropdown <Dropdown
@@ -29,15 +33,27 @@ export const SidebarActions = memo(function SidebarActions() {
{ {
key: 'create-request', key: 'create-request',
label: 'New Request', label: 'New Request',
leftSlot: <Icon icon="plus" />, hotkeyAction: 'request.create',
onSelect: () => createRequest.mutate({}), onSelect: () => createRequest.mutate({}),
}, },
{ {
key: 'create-folder', key: 'create-folder',
label: 'New Folder', label: 'New Folder',
leftSlot: <Icon icon="plus" />,
onSelect: () => createFolder.mutate({}), onSelect: () => createFolder.mutate({}),
}, },
{
key: 'create-workspace',
label: 'New Workspace',
onSelect: async () => {
const name = await prompt({
name: 'name',
label: 'Name',
defaultValue: 'My Workspace',
title: 'New Workspace',
});
createWorkspace.mutate({ name });
},
},
]} ]}
> >
<IconButton size="sm" icon="plusCircle" title="Add Resource" /> <IconButton size="sm" icon="plusCircle" title="Add Resource" />

View File

@@ -8,8 +8,6 @@ import type {
} from 'react'; } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useWindowSize } from 'react-use'; import { useWindowSize } from 'react-use';
import { useHotkey } from '../hooks/useHotkey';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useOsInfo } from '../hooks/useOsInfo'; import { useOsInfo } from '../hooks/useOsInfo';
import { useSidebarHidden } from '../hooks/useSidebarHidden'; import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useSidebarWidth } from '../hooks/useSidebarWidth'; import { useSidebarWidth } from '../hooks/useSidebarWidth';
@@ -31,7 +29,7 @@ const WINDOW_FLOATING_SIDEBAR_WIDTH = 600;
export default function Workspace() { export default function Workspace() {
const { setWidth, width, resetWidth } = useSidebarWidth(); const { setWidth, width, resetWidth } = useSidebarWidth();
const { hide, show, hidden, toggle } = useSidebarHidden(); const { hide, show, hidden } = useSidebarHidden();
const windowSize = useWindowSize(); const windowSize = useWindowSize();
const [floating, setFloating] = useState<boolean>(false); const [floating, setFloating] = useState<boolean>(false);
@@ -40,8 +38,6 @@ export default function Workspace() {
null, null,
); );
useHotkey('sidebar.toggle', toggle);
// float/un-float sidebar on window resize // float/un-float sidebar on window resize
useEffect(() => { useEffect(() => {
const shouldHide = windowSize.width <= WINDOW_FLOATING_SIDEBAR_WIDTH; const shouldHide = windowSize.width <= WINDOW_FLOATING_SIDEBAR_WIDTH;

View File

@@ -3,15 +3,9 @@ import classNames from 'classnames';
import { memo, useMemo } from 'react'; import { memo, useMemo } from 'react';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace'; import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useAppRoutes } from '../hooks/useAppRoutes'; import { useAppRoutes } from '../hooks/useAppRoutes';
import { useAppVersion } from '../hooks/useAppVersion';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace'; import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
import { useExportData } from '../hooks/useExportData';
import { useImportData } from '../hooks/useImportData';
import { usePrompt } from '../hooks/usePrompt'; import { usePrompt } from '../hooks/usePrompt';
import { getRecentEnvironments } from '../hooks/useRecentEnvironments'; import { getRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useTheme } from '../hooks/useTheme';
import { useUpdateMode } from '../hooks/useUpdateMode';
import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace'; import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
import { useWorkspaces } from '../hooks/useWorkspaces'; import { useWorkspaces } from '../hooks/useWorkspaces';
import type { ButtonProps } from './core/Button'; import type { ButtonProps } from './core/Button';
@@ -32,17 +26,11 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
const workspaces = useWorkspaces(); const workspaces = useWorkspaces();
const activeWorkspace = useActiveWorkspace(); const activeWorkspace = useActiveWorkspace();
const activeWorkspaceId = activeWorkspace?.id ?? null; const activeWorkspaceId = activeWorkspace?.id ?? null;
const createWorkspace = useCreateWorkspace({ navigateAfter: true });
const updateWorkspace = useUpdateWorkspace(activeWorkspaceId); const updateWorkspace = useUpdateWorkspace(activeWorkspaceId);
const deleteWorkspace = useDeleteWorkspace(activeWorkspace); const deleteWorkspace = useDeleteWorkspace(activeWorkspace);
const importData = useImportData();
const exportData = useExportData();
const { appearance, toggleAppearance } = useTheme();
const dialog = useDialog(); const dialog = useDialog();
const prompt = usePrompt(); const prompt = usePrompt();
const routes = useAppRoutes(); const routes = useAppRoutes();
const appVersion = useAppVersion();
const [updateMode, setUpdateMode] = useUpdateMode();
const items: DropdownItem[] = useMemo(() => { const items: DropdownItem[] = useMemo(() => {
const workspaceItems: DropdownItem[] = workspaces.map((w) => ({ const workspaceItems: DropdownItem[] = workspaces.map((w) => ({
@@ -134,67 +122,14 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
onSelect: deleteWorkspace.mutate, onSelect: deleteWorkspace.mutate,
variant: 'danger', variant: 'danger',
}, },
{ type: 'separator' },
{
key: 'create-workspace',
label: 'New Workspace',
leftSlot: <Icon icon="plus" />,
onSelect: async () => {
const name = await prompt({
name: 'name',
label: 'Name',
defaultValue: 'My Workspace',
title: 'New Workspace',
});
createWorkspace.mutate({ name });
},
},
{
key: 'import-data',
label: 'Import Data',
leftSlot: <Icon icon="download" />,
onSelect: () => importData.mutate(),
},
{
key: 'export-data',
label: 'Export Data',
leftSlot: <Icon icon="upload" />,
onSelect: () => exportData.mutate(),
},
{ type: 'separator', label: `v${appVersion.data}` },
{
key: 'appearance',
label: 'Toggle Theme',
onSelect: toggleAppearance,
leftSlot: <Icon icon={appearance === 'dark' ? 'sun' : 'moon'} />,
},
{
key: 'update-mode',
label: updateMode === 'stable' ? 'Enable Beta' : 'Disable Beta',
onSelect: () => setUpdateMode(updateMode === 'stable' ? 'beta' : 'stable'),
leftSlot: <Icon icon="camera" />,
},
{
key: 'update-check',
label: 'Check for Updates',
onSelect: () => invoke('check_for_updates'),
leftSlot: <Icon icon="update" />,
},
]; ];
}, [ }, [
activeWorkspace?.name, activeWorkspace?.name,
activeWorkspaceId, activeWorkspaceId,
appearance,
createWorkspace,
deleteWorkspace.mutate, deleteWorkspace.mutate,
dialog, dialog,
exportData,
importData,
prompt, prompt,
routes, routes,
setUpdateMode,
toggleAppearance,
updateMode,
updateWorkspace, updateWorkspace,
workspaces, workspaces,
]); ]);

View File

@@ -6,7 +6,7 @@ import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks'; import { HStack } from './core/Stacks';
import { EnvironmentActionsDropdown } from './EnvironmentActionsDropdown'; import { EnvironmentActionsDropdown } from './EnvironmentActionsDropdown';
import { RecentRequestsDropdown } from './RecentRequestsDropdown'; import { RecentRequestsDropdown } from './RecentRequestsDropdown';
import { RequestActionsDropdown } from './RequestActionsDropdown'; import { SettingsDropdown } from './SettingsDropdown';
import { SidebarActions } from './SidebarActions'; import { SidebarActions } from './SidebarActions';
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown'; import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
@@ -36,14 +36,14 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
<RecentRequestsDropdown /> <RecentRequestsDropdown />
</div> </div>
<div className="flex-1 flex justify-end -mr-2 pointer-events-none"> <div className="flex-1 flex justify-end -mr-2 pointer-events-none">
<RequestActionsDropdown requestId={activeRequest?.id ?? null}> <SettingsDropdown requestId={activeRequest?.id ?? null}>
<IconButton <IconButton
size="sm" size="sm"
title="Request Options" title="Request Options"
icon="gear" icon="gear"
className="pointer-events-auto" className="pointer-events-auto"
/> />
</RequestActionsDropdown> </SettingsDropdown>
</div> </div>
</HStack> </HStack>
); );

View File

@@ -2,7 +2,7 @@ import classNames from 'classnames';
import type { HTMLAttributes, ReactNode } from 'react'; import type { HTMLAttributes, ReactNode } from 'react';
import { forwardRef, memo, useImperativeHandle, useMemo, useRef } from 'react'; import { forwardRef, memo, useImperativeHandle, useMemo, useRef } from 'react';
import type { HotkeyAction } from '../../hooks/useHotkey'; import type { HotkeyAction } from '../../hooks/useHotkey';
import { useHotkey } from '../../hooks/useHotkey'; import { useFormattedHotkey, useHotkey } from '../../hooks/useHotkey';
import { Icon } from './Icon'; import { Icon } from './Icon';
const colorStyles = { const colorStyles = {
@@ -47,11 +47,15 @@ const _Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
rightSlot, rightSlot,
disabled, disabled,
hotkeyAction, hotkeyAction,
title,
onClick, onClick,
...props ...props
}: ButtonProps, }: ButtonProps,
ref, ref,
) { ) {
const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null);
const fullTitle = hotkeyTrigger ? `${title} ${hotkeyTrigger}` : title;
const classes = useMemo( const classes = useMemo(
() => () =>
classNames( classNames(
@@ -88,6 +92,7 @@ const _Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
className={classes} className={classes}
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
title={fullTitle}
{...props} {...props}
> >
{isLoading ? ( {isLoading ? (

View File

@@ -20,8 +20,10 @@ import React, {
useState, useState,
} from 'react'; } from 'react';
import { useKey, useKeyPressEvent, useWindowSize } from 'react-use'; import { useKey, useKeyPressEvent, useWindowSize } from 'react-use';
import type { HotkeyAction } from '../../hooks/useHotkey';
import { Overlay } from '../Overlay'; import { Overlay } from '../Overlay';
import { Button } from './Button'; import { Button } from './Button';
import { HotKey } from './HotKey';
import { Separator } from './Separator'; import { Separator } from './Separator';
import { VStack } from './Stacks'; import { VStack } from './Stacks';
@@ -30,19 +32,20 @@ export type DropdownItemSeparator = {
label?: string; label?: string;
}; };
export type DropdownItem = export type DropdownItemDefault = {
| { key: string;
key: string; type?: 'default';
type?: 'default'; label: ReactNode;
label: ReactNode; hotkeyAction?: HotkeyAction;
variant?: 'danger'; variant?: 'danger';
disabled?: boolean; disabled?: boolean;
hidden?: boolean; hidden?: boolean;
leftSlot?: ReactNode; leftSlot?: ReactNode;
rightSlot?: ReactNode; rightSlot?: ReactNode;
onSelect?: () => void; onSelect?: () => void;
} };
| DropdownItemSeparator;
export type DropdownItem = DropdownItemDefault | DropdownItemSeparator;
export interface DropdownProps { export interface DropdownProps {
children: ReactElement<HTMLAttributes<HTMLButtonElement>>; children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
@@ -126,9 +129,10 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
{open && triggerRect && ( {open && triggerRect && (
<Menu <Menu
ref={menuRef} ref={menuRef}
showTriangle
defaultSelectedIndex={defaultSelectedIndex} defaultSelectedIndex={defaultSelectedIndex}
items={items} items={items}
triggerRect={triggerRect} triggerShape={triggerRect}
onClose={handleClose} onClose={handleClose}
/> />
)} )}
@@ -136,16 +140,53 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
); );
}); });
interface ContextMenuProps {
show: { x: number; y: number } | null;
className?: string;
items: DropdownProps['items'];
onClose: () => void;
}
export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(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 (
<Menu
className={className}
ref={ref}
items={items}
onClose={onClose}
triggerShape={triggerShape}
/>
);
});
interface MenuProps { interface MenuProps {
className?: string; className?: string;
defaultSelectedIndex?: number; defaultSelectedIndex?: number;
items: DropdownProps['items']; items: DropdownProps['items'];
triggerRect: DOMRect; triggerShape: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right'>;
onClose: () => void; onClose: () => void;
showTriangle?: boolean;
} }
const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuProps>(function Menu( const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuProps>(function Menu(
{ className, items, onClose, triggerRect, defaultSelectedIndex }: MenuProps, { className, items, onClose, triggerShape, defaultSelectedIndex, showTriangle }: MenuProps,
ref, ref,
) { ) {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
@@ -248,21 +289,27 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
const { containerStyles, triangleStyles } = useMemo<{ const { containerStyles, triangleStyles } = useMemo<{
containerStyles: CSSProperties; containerStyles: CSSProperties;
triangleStyles: CSSProperties; triangleStyles: CSSProperties | null;
}>(() => { }>(() => {
const docWidth = document.documentElement.getBoundingClientRect().width; const docRect = document.documentElement.getBoundingClientRect();
const spaceRemaining = docWidth - triggerRect.left; const width = triggerShape.right - triggerShape.left;
const top = triggerRect?.bottom + 5; const hSpaceRemaining = docRect.width - triggerShape.left;
const onRight = spaceRemaining < 200; const vSpaceRemaining = docRect.height - triggerShape.bottom;
const containerStyles = onRight const top = triggerShape?.bottom + 5;
? { top, right: docWidth - triggerRect?.right } const onRight = hSpaceRemaining < 200;
: { top, left: triggerRect?.left }; 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 size = { top: '-0.2rem', width: '0.4rem', height: '0.4rem' };
const triangleStyles = onRight const triangleStyles = onRight
? { right: triggerRect.width / 2, marginRight: '-0.2rem', ...size } ? { right: width / 2, marginRight: '-0.2rem', ...size }
: { left: triggerRect.width / 2, marginLeft: '-0.2rem', ...size }; : { left: width / 2, marginLeft: '-0.2rem', ...size };
return { containerStyles, triangleStyles }; return { containerStyles, triangleStyles };
}, [triggerRect]); }, [triggerShape]);
const handleFocus = useCallback( const handleFocus = useCallback(
(i: DropdownItem) => { (i: DropdownItem) => {
@@ -290,11 +337,13 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
style={containerStyles} style={containerStyles}
className={classNames(className, 'outline-none mt-1 pointer-events-auto fixed z-50')} className={classNames(className, 'outline-none mt-1 pointer-events-auto fixed z-50')}
> >
<span {triangleStyles && showTriangle && (
aria-hidden <span
style={triangleStyles} aria-hidden
className="bg-gray-50 absolute rotate-45 border-gray-200 border-t border-l" style={triangleStyles}
/> className="bg-gray-50 absolute rotate-45 border-gray-200 border-t border-l"
/>
)}
{containerStyles && ( {containerStyles && (
<VStack <VStack
space={0.5} space={0.5}
@@ -333,9 +382,9 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
interface MenuItemProps { interface MenuItemProps {
className?: string; className?: string;
item: DropdownItem; item: DropdownItemDefault;
onSelect: (item: DropdownItem) => void; onSelect: (item: DropdownItemDefault) => void;
onFocus: (item: DropdownItem) => void; onFocus: (item: DropdownItemDefault) => void;
focused: boolean; focused: boolean;
} }
@@ -359,7 +408,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
[focused], [focused],
); );
if (item.type === 'separator') return <Separator className="my-1.5" />; const rightSlot = item.rightSlot ?? <HotKey action={item.hotkeyAction ?? null} />;
return ( return (
<Button <Button
@@ -373,7 +422,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
onClick={handleClick} onClick={handleClick}
justify="start" justify="start"
leftSlot={item.leftSlot && <div className="pr-2 flex justify-start">{item.leftSlot}</div>} leftSlot={item.leftSlot && <div className="pr-2 flex justify-start">{item.leftSlot}</div>}
rightSlot={item.rightSlot && <div className="ml-auto pl-3">{item.rightSlot}</div>} rightSlot={rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>}
className={classNames( className={classNames(
className, className,
'min-w-[8rem] outline-none px-2 mx-1.5 flex text-sm text-gray-700 whitespace-nowrap', 'min-w-[8rem] outline-none px-2 mx-1.5 flex text-sm text-gray-700 whitespace-nowrap',

View File

@@ -1,21 +1,20 @@
import classNames from 'classnames'; import classNames from 'classnames';
import type { HotkeyAction } from '../../hooks/useHotkey';
import { useFormattedHotkey } from '../../hooks/useHotkey';
import { useOsInfo } from '../../hooks/useOsInfo';
interface Props { interface Props {
modifier: 'Meta' | 'Control' | 'Shift'; action: HotkeyAction | null;
keyName: string;
} }
const keys: Record<Props['modifier'], string> = { export function HotKey({ action }: Props) {
Control: '⌃', const osinfo = useOsInfo();
Meta: '⌘', const label = useFormattedHotkey(action);
Shift: '⇧', if (label === null || osinfo == null) {
}; return null;
}
export function HotKey({ modifier, keyName }: Props) {
return ( return (
<span className={classNames('text-sm text-gray-600')}> <span className={classNames('text-sm text-gray-1000 text-opacity-disabled')}>{label}</span>
{keys[modifier]}
{keyName}
</span>
); );
} }

View File

@@ -1,44 +1,4 @@
import { import * as ReactIcons from '@radix-ui/react-icons';
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 classNames from 'classnames'; import classNames from 'classnames';
import type { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { memo } 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'; import { ReactComponent as LeftPanelVisibleIcon } from '../../assets/icons/LeftPanelVisibleIcon.svg';
const icons = { const icons = {
archive: ArchiveIcon, archive: ReactIcons.ArchiveIcon,
camera: CameraIcon, camera: ReactIcons.CameraIcon,
check: CheckIcon, check: ReactIcons.CheckIcon,
checkbox: CheckboxIcon, checkbox: ReactIcons.CheckboxIcon,
clock: ClockIcon, clock: ReactIcons.ClockIcon,
chevronDown: ChevronDownIcon, chevronDown: ReactIcons.ChevronDownIcon,
chevronRight: ChevronRightIcon, chevronRight: ReactIcons.ChevronRightIcon,
code: CodeIcon, code: ReactIcons.CodeIcon,
colorWheel: ColorWheelIcon, colorWheel: ReactIcons.ColorWheelIcon,
copy: CopyIcon, copy: ReactIcons.CopyIcon,
dividerH: DividerHorizontalIcon, dividerH: ReactIcons.DividerHorizontalIcon,
dotsH: DotsHorizontalIcon, dotsH: ReactIcons.DotsHorizontalIcon,
dotsV: DotsVerticalIcon, dotsV: ReactIcons.DotsVerticalIcon,
download: DownloadIcon, download: ReactIcons.DownloadIcon,
drag: DragHandleDots2Icon, drag: ReactIcons.DragHandleDots2Icon,
eye: EyeOpenIcon, eye: ReactIcons.EyeOpenIcon,
eyeClosed: EyeClosedIcon, eyeClosed: ReactIcons.EyeClosedIcon,
gear: GearIcon, gear: ReactIcons.GearIcon,
hamburger: HamburgerMenuIcon, hamburger: ReactIcons.HamburgerMenuIcon,
home: HomeIcon, 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, leftPanelHidden: LeftPanelHiddenIcon,
leftPanelVisible: LeftPanelVisibleIcon, 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<HTMLSpanElement>) => <span {...props} />, empty: (props: HTMLAttributes<HTMLSpanElement>) => <span {...props} />,
}; };

View File

@@ -38,6 +38,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButt
}, },
[onClick, setConfirmed, showConfirm], [onClick, setConfirmed, showConfirm],
); );
return ( return (
<Button <Button
ref={ref} ref={ref}

View File

@@ -24,11 +24,7 @@ export function useCreateEnvironment() {
label: 'Name', label: 'Name',
defaultValue: 'My Environment', defaultValue: 'My Environment',
}); });
const variables = return invoke('create_environment', { name, variables: [], workspaceId });
environments.length === 0 && workspaces.length === 1
? [{ name: 'first_variable', value: 'some reusable value' }]
: [];
return invoke('create_environment', { name, variables, workspaceId });
}, },
onSettled: () => trackEvent('environment', 'create'), onSettled: () => trackEvent('environment', 'create'),
onSuccess: async (environment) => { onSuccess: async (environment) => {

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useOsInfo } from './useOsInfo';
export type HotkeyAction = export type HotkeyAction =
| 'request.send' | 'request.send'
@@ -7,7 +8,7 @@ export type HotkeyAction =
| 'sidebar.toggle' | 'sidebar.toggle'
| 'sidebar.focus' | 'sidebar.focus'
| 'urlBar.focus' | 'urlBar.focus'
| 'environmentEditor.show'; | 'environmentEditor.toggle';
const hotkeys: Record<HotkeyAction, string[]> = { const hotkeys: Record<HotkeyAction, string[]> = {
'request.send': ['Meta+Enter', 'Meta+r'], 'request.send': ['Meta+Enter', 'Meta+r'],
@@ -16,10 +17,18 @@ const hotkeys: Record<HotkeyAction, string[]> = {
'sidebar.toggle': ['Meta+b'], 'sidebar.toggle': ['Meta+b'],
'sidebar.focus': ['Meta+1'], 'sidebar.focus': ['Meta+1'],
'urlBar.focus': ['Meta+l'], 'urlBar.focus': ['Meta+l'],
'environmentEditor.show': ['Meta+e'], 'environmentEditor.toggle': ['Meta+e'],
}; };
export function useHotkey(action: HotkeyAction | null, callback: (e: KeyboardEvent) => void) { export function useHotkey(action: HotkeyAction | null, callback: (e: KeyboardEvent) => void) {
useAnyHotkey((hkAction, e) => {
if (hkAction === action) {
callback(e);
}
});
}
export function useAnyHotkey(callback: (action: HotkeyAction, e: KeyboardEvent) => void) {
const currentKeys = useRef<Set<string>>(new Set()); const currentKeys = useRef<Set<string>>(new Set());
const callbackRef = useRef(callback); const callbackRef = useRef(callback);
@@ -30,19 +39,17 @@ export function useHotkey(action: HotkeyAction | null, callback: (e: KeyboardEve
useEffect(() => { useEffect(() => {
const down = (e: KeyboardEvent) => { const down = (e: KeyboardEvent) => {
currentKeys.current.add(e.key); currentKeys.current.add(e.key);
for (const [hkAction, hkKeys] of Object.entries(hotkeys)) { for (const [hkAction, hkKeys] of Object.entries(hotkeys) as [HotkeyAction, string[]][]) {
for (const hkKey of hkKeys) { for (const hkKey of hkKeys) {
const keys = hkKey.split('+'); const keys = hkKey.split('+');
if ( if (
keys.length === currentKeys.current.size && keys.length === currentKeys.current.size &&
keys.every((key) => currentKeys.current.has(key)) && keys.every((key) => currentKeys.current.has(key))
hkAction === action
) { ) {
// Triggered hotkey! // Triggered hotkey!
console.log('TRIGGER!', action);
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
callbackRef.current(e); callbackRef.current(hkAction, e);
} }
} }
} }
@@ -56,6 +63,45 @@ export function useHotkey(action: HotkeyAction | null, callback: (e: KeyboardEve
window.removeEventListener('keydown', down); window.removeEventListener('keydown', down);
window.removeEventListener('keyup', up); window.removeEventListener('keyup', up);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps }, []);
}, [action, callback]); }
export function useFormattedHotkey(action: HotkeyAction | null): string | null {
const osInfo = useOsInfo();
const trigger = action != null ? hotkeys[action]?.[0] ?? null : null;
if (trigger == null || osInfo == null) {
return null;
}
const os = osInfo.osType;
const parts = trigger.split('+');
const labelParts: string[] = [];
for (const p of parts) {
if (os === 'Darwin') {
if (p === 'Meta') {
labelParts.push('⌘');
} else if (p === 'Shift') {
labelParts.push('⇧');
} else if (p === 'Control') {
labelParts.push('⌃');
} else if (p === 'Enter') {
labelParts.push('↩');
} else {
labelParts.push(p.toUpperCase());
}
} else {
if (p === 'Meta') {
labelParts.push('Ctrl');
} else {
labelParts.push(p);
}
}
}
if (os === 'Darwin') {
return labelParts.join('');
} else {
return labelParts.join('+');
}
} }