Good hotkey support

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

View File

@@ -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: <Icon icon="gear" />,
onSelect: showEnvironmentDialog,
},

View File

@@ -43,6 +43,11 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
[environments, selectedEnvironmentId],
);
const handleCreateEnvironment = async () => {
const e = await createEnvironment.mutateAsync();
setSelectedEnvironmentId(e.id);
};
return (
<div
className={classNames(
@@ -76,7 +81,7 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
className="w-full text-center"
color="gray"
justify="center"
onClick={() => createEnvironment.mutate()}
onClick={handleCreateEnvironment}
>
New Environment
</Button>
@@ -191,15 +196,24 @@ const EnvironmentEditor = function ({
<h1 className="text-xl">{environment?.name ?? 'Base Environment'}</h1>
{items != null && (
<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>
)}
{environment == null && (
<span className="text-sm italic text-gray-500">
Base variables available at all times
</span>
)}
</HStack>
<PairEditor
nameAutocomplete={nameAutocomplete}
nameAutocompleteVariables={false}
namePlaceholder="VAR_NAME"
valuePlaceholder="variable value"
nameValidate={validateName}
valueAutocompleteVariables={false}
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 { 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<string, TreeNode>;
selectableRequests: { id: string; index: number; tree: TreeNode }[];
selectableRequests: {
id: string;
index: number;
tree: 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) {
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<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
console.log('CONTEXT MENU');
setShowContextMenu({ x: e.clientX, y: e.clientY });
}, []);
return (
<li ref={ref}>
<div className={classNames(className, 'block relative group/item px-2 pb-0.5')}>
{itemModel === 'folder' && (
<Dropdown
items={[
{
key: 'sendAll',
label: 'Send All',
leftSlot: <Icon icon="paperPlane" />,
onSelect: () => sendManyRequests.mutate(child.children.map((c) => c.item.id)),
},
{ type: 'separator', label: itemName },
{
key: 'rename',
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
onSelect: async () => {
const name = await prompt({
title: 'Rename Folder',
description: (
<>
Enter a new name for <InlineCode>{itemName}</InlineCode>
</>
),
name: 'name',
label: 'Name',
defaultValue: itemName,
});
updateAnyFolder.mutate({ id: itemId, update: (f) => ({ ...f, name }) });
},
},
{
key: 'deleteFolder',
label: 'Delete',
variant: 'danger',
leftSlot: <Icon icon="trash" />,
onSelect: () => deleteFolder.mutate(),
},
{ type: 'separator' },
{
key: 'createRequest',
label: 'New Request',
leftSlot: <Icon icon="plus" />,
onSelect: () => createRequest.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"
/>
</Dropdown>
)}
<ContextMenu
show={showContextMenu}
items={
itemModel === 'folder'
? [
{
key: 'sendAll',
label: 'Send All',
leftSlot: <Icon icon="paperPlane" />,
onSelect: () => sendManyRequests.mutate(child.children.map((c) => c.item.id)),
},
{ type: 'separator', label: itemName },
{
key: 'rename',
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
onSelect: async () => {
const name = await prompt({
title: 'Rename Folder',
description: (
<>
Enter a new name for <InlineCode>{itemName}</InlineCode>
</>
),
name: 'name',
label: 'Name',
defaultValue: itemName,
});
updateAnyFolder.mutate({ id: itemId, update: (f) => ({ ...f, name }) });
},
},
{
key: 'deleteFolder',
label: 'Delete',
variant: 'danger',
leftSlot: <Icon icon="trash" />,
onSelect: () => deleteFolder.mutate(),
},
{ type: 'separator' },
{
key: 'createRequest',
label: 'New Request',
hotkeyAction: 'request.create',
leftSlot: <Icon icon="plus" />,
onSelect: () => createRequest.mutate({ folderId: itemId, sortPriority: -1 }),
},
{
key: 'createFolder',
label: 'New Folder',
leftSlot: <Icon icon="plus" />,
onSelect: () => createFolder.mutate({ folderId: itemId, sortPriority: -1 }),
},
]
: [
{
key: 'duplicateRequest',
label: 'Duplicate',
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
// tabIndex={-1} // Will prevent drag-n-drop
disabled={editing}
onClick={handleSelect}
onDoubleClick={handleStartEditing}
onContextMenu={handleContextMenu}
data-active={isActive}
data-selected={selected}
className={classNames(
@@ -710,7 +754,13 @@ function DraggableSidebarItem({
[onMove],
);
const [{ isDragging }, connectDrag] = useDrag<DragItem, unknown, { isDragging: boolean }>(
const [{ isDragging }, connectDrag] = useDrag<
DragItem,
unknown,
{
isDragging: boolean;
}
>(
() => ({
type: ItemTypes.REQUEST,
item: () => {

View File

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

View File

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

View File

@@ -3,15 +3,9 @@ import classNames from 'classnames';
import { memo, useMemo } from 'react';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useAppVersion } from '../hooks/useAppVersion';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
import { useExportData } from '../hooks/useExportData';
import { useImportData } from '../hooks/useImportData';
import { usePrompt } from '../hooks/usePrompt';
import { getRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useTheme } from '../hooks/useTheme';
import { useUpdateMode } from '../hooks/useUpdateMode';
import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
import { useWorkspaces } from '../hooks/useWorkspaces';
import type { ButtonProps } from './core/Button';
@@ -32,17 +26,11 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
const workspaces = useWorkspaces();
const activeWorkspace = useActiveWorkspace();
const activeWorkspaceId = activeWorkspace?.id ?? null;
const createWorkspace = useCreateWorkspace({ navigateAfter: true });
const updateWorkspace = useUpdateWorkspace(activeWorkspaceId);
const deleteWorkspace = useDeleteWorkspace(activeWorkspace);
const importData = useImportData();
const exportData = useExportData();
const { appearance, toggleAppearance } = useTheme();
const dialog = useDialog();
const prompt = usePrompt();
const routes = useAppRoutes();
const appVersion = useAppVersion();
const [updateMode, setUpdateMode] = useUpdateMode();
const items: DropdownItem[] = useMemo(() => {
const workspaceItems: DropdownItem[] = workspaces.map((w) => ({
@@ -134,67 +122,14 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
onSelect: deleteWorkspace.mutate,
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,
activeWorkspaceId,
appearance,
createWorkspace,
deleteWorkspace.mutate,
dialog,
exportData,
importData,
prompt,
routes,
setUpdateMode,
toggleAppearance,
updateMode,
updateWorkspace,
workspaces,
]);

View File

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

View File

@@ -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<HTMLButtonElement, ButtonProps>(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<HTMLButtonElement, ButtonProps>(function Button(
className={classes}
disabled={disabled}
onClick={onClick}
title={fullTitle}
{...props}
>
{isLoading ? (

View File

@@ -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<HTMLAttributes<HTMLButtonElement>>;
@@ -126,9 +129,10 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
{open && triggerRect && (
<Menu
ref={menuRef}
showTriangle
defaultSelectedIndex={defaultSelectedIndex}
items={items}
triggerRect={triggerRect}
triggerShape={triggerRect}
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 {
className?: string;
defaultSelectedIndex?: number;
items: DropdownProps['items'];
triggerRect: DOMRect;
triggerShape: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right'>;
onClose: () => void;
showTriangle?: boolean;
}
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,
) {
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -248,21 +289,27 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, 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<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
style={containerStyles}
className={classNames(className, 'outline-none mt-1 pointer-events-auto fixed z-50')}
>
<span
aria-hidden
style={triangleStyles}
className="bg-gray-50 absolute rotate-45 border-gray-200 border-t border-l"
/>
{triangleStyles && showTriangle && (
<span
aria-hidden
style={triangleStyles}
className="bg-gray-50 absolute rotate-45 border-gray-200 border-t border-l"
/>
)}
{containerStyles && (
<VStack
space={0.5}
@@ -333,9 +382,9 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, 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 <Separator className="my-1.5" />;
const rightSlot = item.rightSlot ?? <HotKey action={item.hotkeyAction ?? null} />;
return (
<Button
@@ -373,7 +422,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
onClick={handleClick}
justify="start"
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,
'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 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<Props['modifier'], string> = {
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 (
<span className={classNames('text-sm text-gray-600')}>
{keys[modifier]}
{keyName}
</span>
<span className={classNames('text-sm text-gray-1000 text-opacity-disabled')}>{label}</span>
);
}

View File

@@ -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<HTMLSpanElement>) => <span {...props} />,
};

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react';
import { useOsInfo } from './useOsInfo';
export type HotkeyAction =
| 'request.send'
@@ -7,7 +8,7 @@ export type HotkeyAction =
| 'sidebar.toggle'
| 'sidebar.focus'
| 'urlBar.focus'
| 'environmentEditor.show';
| 'environmentEditor.toggle';
const hotkeys: Record<HotkeyAction, string[]> = {
'request.send': ['Meta+Enter', 'Meta+r'],
@@ -16,10 +17,18 @@ const hotkeys: Record<HotkeyAction, string[]> = {
'sidebar.toggle': ['Meta+b'],
'sidebar.focus': ['Meta+1'],
'urlBar.focus': ['Meta+l'],
'environmentEditor.show': ['Meta+e'],
'environmentEditor.toggle': ['Meta+e'],
};
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 callbackRef = useRef(callback);
@@ -30,19 +39,17 @@ export function useHotkey(action: HotkeyAction | null, callback: (e: KeyboardEve
useEffect(() => {
const down = (e: KeyboardEvent) => {
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) {
const keys = hkKey.split('+');
if (
keys.length === currentKeys.current.size &&
keys.every((key) => currentKeys.current.has(key)) &&
hkAction === action
keys.every((key) => currentKeys.current.has(key))
) {
// Triggered hotkey!
console.log('TRIGGER!', action);
e.preventDefault();
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('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('+');
}
}