Compare commits

...

8 Commits

Author SHA1 Message Date
Gregory Schier
626aacf982 Bump version to 2024.0.0 2024-01-08 15:57:59 -08:00
Gregory Schier
d5855c45a6 Hotkey labels 2024-01-08 15:57:21 -08:00
Gregory Schier
793bff9f27 Show hotkeys on empty views 2024-01-08 15:13:44 -08:00
Gregory Schier
88ea68e72f Remove base env, fix hotkeys, and QoL improvements 2024-01-07 22:24:19 -08:00
Gregory Schier
35e40d2c55 Fix hotkeys getting stuck on cmd+tab 2024-01-07 21:32:25 -08:00
Gregory Schier
c472b83409 Always show settings dropdown 2023-11-22 09:39:30 -08:00
Gregory Schier
52c26d235c Tweak margin 2023-11-22 09:37:50 -08:00
Gregory Schier
ac54729012 Fix bottom-up dropdown positioning 2023-11-22 09:35:56 -08:00
22 changed files with 239 additions and 110 deletions

View File

@@ -62,7 +62,7 @@
},
"devDependencies": {
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
"@tauri-apps/cli": "^1.5.4",
"@tauri-apps/cli": "^1.5.6",
"@types/node": "^18.7.10",
"@types/papaparse": "^5.3.7",
"@types/parse-color": "^1.0.1",

View File

@@ -8,7 +8,7 @@
},
"package": {
"productName": "Yaak",
"version": "2023.4.0-beta.4"
"version": "2024.0.0"
},
"tauri": {
"windows": [],

View File

@@ -2,6 +2,7 @@ import classNames from 'classnames';
import { memo, useCallback, useMemo } from 'react';
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { useEnvironments } from '../hooks/useEnvironments';
import { useHotkey } from '../hooks/useHotkey';
import type { ButtonProps } from './core/Button';
@@ -22,6 +23,7 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
}: Props) {
const environments = useEnvironments();
const activeEnvironment = useActiveEnvironment();
const createEnvironment = useCreateEnvironment();
const dialog = useDialog();
const routes = useAppRoutes();
@@ -33,7 +35,7 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
});
}, [dialog, activeEnvironment]);
useHotkey('environmentEditor.toggle', showEnvironmentDialog);
useHotkey('environmentEditor.toggle', showEnvironmentDialog, { enable: environments.length > 0 });
const items: DropdownItem[] = useMemo(
() => [
@@ -55,15 +57,25 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
...((environments.length > 0
? [{ type: 'separator', label: 'Environments' }]
: []) as DropdownItem[]),
{
key: 'edit',
label: 'Manage Environments',
hotkeyAction: 'environmentEditor.toggle',
leftSlot: <Icon icon="gear" />,
onSelect: showEnvironmentDialog,
},
environments.length
? {
key: 'edit',
label: 'Manage Environments',
hotkeyAction: 'environmentEditor.toggle',
leftSlot: <Icon icon="gear" />,
onSelect: showEnvironmentDialog,
}
: {
key: 'new',
label: 'New Environment',
leftSlot: <Icon icon="plus" />,
onSelect: async () => {
await createEnvironment.mutateAsync();
showEnvironmentDialog();
},
},
],
[activeEnvironment, environments, routes, showEnvironmentDialog],
[activeEnvironment?.id, createEnvironment, environments, routes, showEnvironmentDialog],
);
return (

View File

@@ -58,23 +58,15 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
{showSidebar && (
<aside className="grid grid-rows-[minmax(0,1fr)_auto] gap-y-0.5 h-full max-w-[250px] pr-3 border-r border-gray-100 -ml-2">
<div className="min-w-0 h-full w-full overflow-y-scroll">
<SidebarButton
active={selectedEnvironment == null}
onClick={() => setSelectedEnvironmentId(null)}
>
Base Environment
</SidebarButton>
<div className="ml-3 pl-2 border-l border-highlight">
{environments.map((e) => (
<SidebarButton
key={e.id}
active={selectedEnvironment?.id === e.id}
onClick={() => setSelectedEnvironmentId(e.id)}
>
{e.name}
</SidebarButton>
))}
</div>
{environments.map((e) => (
<SidebarButton
key={e.id}
active={selectedEnvironment?.id === e.id}
onClick={() => setSelectedEnvironmentId(e.id)}
>
{e.name}
</SidebarButton>
))}
</div>
<Button
size="sm"
@@ -204,11 +196,6 @@ const EnvironmentEditor = function ({
/>
</Dropdown>
)}
{environment == null && (
<span className="text-sm italic text-gray-500">
Base variables available at all times
</span>
)}
</HStack>
<PairEditor
nameAutocomplete={nameAutocomplete}

View File

@@ -3,8 +3,12 @@ import classNames from 'classnames';
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import { useLocalStorage } from 'react-use';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { useRequests } from '../hooks/useRequests';
import { clamp } from '../lib/clamp';
import { HotKeyList } from './core/HotKeyList';
import { RequestPane } from './RequestPane';
import { ResizeHandle } from './ResizeHandle';
import { ResponsePane } from './ResponsePane';
@@ -24,6 +28,9 @@ const STACK_VERTICAL_WIDTH = 600;
export const RequestResponse = memo(function RequestResponse({ style }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const activeRequest = useActiveRequest();
const createRequest = useCreateRequest();
const requests = useRequests();
const [vertical, setVertical] = useState<boolean>(false);
const [widthRaw, setWidth] = useLocalStorage<number>(`body_width::${useActiveWorkspaceId()}`);
const [heightRaw, setHeight] = useLocalStorage<number>(`body_height::${useActiveWorkspaceId()}`);
@@ -114,6 +121,10 @@ export const RequestResponse = memo(function RequestResponse({ style }: Props) {
[width, height, vertical, setHeight, setWidth],
);
if (activeRequest === null) {
return <HotKeyList hotkeys={['request.create', 'sidebar.toggle']} />;
}
return (
<div ref={containerRef} className="grid gap-1.5 w-full h-full p-3" style={styles}>
<RequestPane style={rqst} fullHeight={!vertical} />

View File

@@ -2,7 +2,7 @@ import classNames from 'classnames';
import type { CSSProperties } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { createGlobalState } from 'react-use';
import { useActiveRequestId } from '../hooks/useActiveRequestId';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useLatestResponse } from '../hooks/useLatestResponse';
import { useResponseContentType } from '../hooks/useResponseContentType';
import { useResponses } from '../hooks/useResponses';
@@ -12,6 +12,7 @@ import { isResponseLoading } from '../lib/models';
import { Banner } from './core/Banner';
import { CountBadge } from './core/CountBadge';
import { DurationTag } from './core/DurationTag';
import { HotKeyList } from './core/HotKeyList';
import { SizeTag } from './core/SizeTag';
import { HStack } from './core/Stacks';
import { StatusTag } from './core/StatusTag';
@@ -34,9 +35,9 @@ const useActiveTab = createGlobalState<string>('body');
export const ResponsePane = memo(function ResponsePane({ style, className }: Props) {
const [pinnedResponseId, setPinnedResponseId] = useState<string | null>(null);
const activeRequestId = useActiveRequestId();
const latestResponse = useLatestResponse(activeRequestId);
const responses = useResponses(activeRequestId);
const activeRequest = useActiveRequest();
const latestResponse = useLatestResponse(activeRequest?.id ?? null);
const responses = useResponses(activeRequest?.id ?? null);
const activeResponse: HttpResponse | null = pinnedResponseId
? responses.find((r) => r.id === pinnedResponseId) ?? null
: latestResponse ?? null;
@@ -84,6 +85,10 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
[activeResponse?.headers, contentType, setViewMode, viewMode],
);
if (activeRequest === null) {
return null;
}
return (
<div
style={style}
@@ -95,6 +100,14 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
)}
>
{activeResponse?.error && <Banner className="m-2">{activeResponse.error}</Banner>}
{!activeResponse && (
<>
<span />
<HotKeyList
hotkeys={['request.send', 'request.create', 'sidebar.toggle', 'urlBar.focus']}
/>
</>
)}
{activeResponse && !activeResponse.error && !isResponseLoading(activeResponse) && (
<>
<HStack

View File

@@ -1,30 +1,26 @@
import { invoke } from '@tauri-apps/api';
import { invoke, shell } 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 { Button } from './core/Button';
import type { DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { VStack } from './core/Stacks';
import { useDialog } from './DialogContext';
interface Props {
requestId: string | null;
children: DropdownProps['children'];
}
export function SettingsDropdown({ requestId, children }: Props) {
export function SettingsDropdown() {
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;
}
const dialog = useDialog();
return (
<Dropdown
@@ -34,7 +30,29 @@ export function SettingsDropdown({ requestId, children }: Props) {
key: 'import-data',
label: 'Import',
leftSlot: <Icon icon="download" />,
onSelect: () => importData.mutate(),
onSelect: () => {
dialog.show({
title: 'Import Data',
size: 'sm',
render: ({ hide }) => {
return (
<VStack space={3}>
<p>Insomnia or Postman Collection v2/v2.1 formats are supported</p>
<Button
size="sm"
color="primary"
onClick={async () => {
await importData.mutateAsync();
hide();
}}
>
Select File
</Button>
</VStack>
);
},
});
},
},
{
key: 'export-data',
@@ -61,9 +79,15 @@ export function SettingsDropdown({ requestId, children }: Props) {
onSelect: () => invoke('check_for_updates'),
leftSlot: <Icon icon="update" />,
},
{
key: 'feedback',
label: 'Feedback',
onSelect: () => shell.open('https://yaak.canny.io'),
leftSlot: <Icon icon="chat" />,
},
]}
>
{children}
<IconButton size="sm" title="Request Options" icon="gear" className="pointer-events-auto" />
</Dropdown>
);
}

View File

@@ -55,6 +55,7 @@ export function Sidebar({ className }: Props) {
const { hidden } = useSidebarHidden();
const sidebarRef = useRef<HTMLLIElement>(null);
const activeRequestId = useActiveRequestId();
const duplicateRequest = useDuplicateRequest({ id: activeRequestId ?? '', navigateAfter: true });
const activeEnvironmentId = useActiveEnvironmentId();
const requests = useRequests();
const folders = useFolders();
@@ -75,6 +76,8 @@ export function Sidebar({ className }: Props) {
namespace: NAMESPACE_NO_SYNC,
});
useHotkey('request.duplicate', () => duplicateRequest.mutate());
const isCollapsed = useCallback(
(id: string) => collapsed.value?.[id] ?? false,
[collapsed.value],
@@ -581,7 +584,6 @@ const SidebarItem = forwardRef(function SidebarItem(
const handleContextMenu = useCallback((e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
console.log('CONTEXT MENU');
setShowContextMenu({ x: e.clientX, y: e.clientY });
}, []);
@@ -647,7 +649,10 @@ const SidebarItem = forwardRef(function SidebarItem(
label: 'Duplicate',
hotkeyAction: 'request.duplicate',
leftSlot: <Icon icon="copy" />,
onSelect: () => duplicateRequest.mutate(),
onSelect: () => {
console.log('DUPLICATE');
duplicateRequest.mutate();
},
},
{
key: 'deleteRequest',

View File

@@ -1,9 +1,7 @@
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 { IconButton } from './core/IconButton';
@@ -12,8 +10,6 @@ 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({}));
@@ -41,19 +37,6 @@ export const SidebarActions = memo(function SidebarActions() {
label: 'New Folder',
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

@@ -41,9 +41,13 @@ export default function Workspace() {
// float/un-float sidebar on window resize
useEffect(() => {
const shouldHide = windowSize.width <= WINDOW_FLOATING_SIDEBAR_WIDTH;
if (shouldHide) setFloating(true);
else if (!shouldHide) setFloating(false);
}, [windowSize.width]);
if (shouldHide && !floating) {
setFloating(true);
hide();
} else if (!shouldHide && floating) {
setFloating(false);
}
}, [floating, hide, windowSize.width]);
const unsub = () => {
if (moveState.current !== null) {

View File

@@ -3,6 +3,7 @@ import classNames from 'classnames';
import { memo, useMemo } from 'react';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
import { usePrompt } from '../hooks/usePrompt';
import { getRecentEnvironments } from '../hooks/useRecentEnvironments';
@@ -28,6 +29,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
const activeWorkspaceId = activeWorkspace?.id ?? null;
const updateWorkspace = useUpdateWorkspace(activeWorkspaceId);
const deleteWorkspace = useDeleteWorkspace(activeWorkspace);
const createWorkspace = useCreateWorkspace({ navigateAfter: true });
const dialog = useDialog();
const prompt = usePrompt();
const routes = useAppRoutes();
@@ -122,6 +124,21 @@ 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 });
},
},
];
}, [
activeWorkspace?.name,

View File

@@ -1,8 +1,6 @@
import classNames from 'classnames';
import React, { memo } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
import { EnvironmentActionsDropdown } from './EnvironmentActionsDropdown';
import { RecentRequestsDropdown } from './RecentRequestsDropdown';
@@ -15,8 +13,6 @@ interface Props {
}
export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Props) {
const activeRequest = useActiveRequest();
return (
<HStack
space={2}
@@ -36,14 +32,7 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
<RecentRequestsDropdown />
</div>
<div className="flex-1 flex justify-end -mr-2 pointer-events-none">
<SettingsDropdown requestId={activeRequest?.id ?? null}>
<IconButton
size="sm"
title="Request Options"
icon="gear"
className="pointer-events-auto"
/>
</SettingsDropdown>
<SettingsDropdown />
</div>
</HStack>
);

View File

@@ -1,6 +1,6 @@
import classNames from 'classnames';
import type { HTMLAttributes, ReactNode } from 'react';
import { forwardRef, memo, useImperativeHandle, useMemo, useRef } from 'react';
import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react';
import type { HotkeyAction } from '../../hooks/useHotkey';
import { useFormattedHotkey, useHotkey } from '../../hooks/useHotkey';
import { Icon } from './Icon';
@@ -31,8 +31,7 @@ export type ButtonProps = HTMLAttributes<HTMLButtonElement> & {
hotkeyAction?: HotkeyAction;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const _Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{
isLoading,
className,
@@ -114,5 +113,3 @@ const _Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
</button>
);
});
export const Button = memo(_Button);

View File

@@ -300,7 +300,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
const upsideDown = vSpaceRemaining < 200;
const containerStyles = {
top: !upsideDown ? top : undefined,
bottom: upsideDown ? top : undefined,
bottom: upsideDown ? docRect.height - top : undefined,
right: onRight ? docRect.width - triggerShape?.right : undefined,
left: !onRight ? triggerShape?.left : undefined,
};
@@ -335,7 +335,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
dir="ltr"
ref={containerRef}
style={containerStyles}
className={classNames(className, 'outline-none mt-1 pointer-events-auto fixed z-50')}
className={classNames(className, 'outline-none my-1 pointer-events-auto fixed z-50')}
>
{triangleStyles && showTriangle && (
<span

View File

@@ -5,16 +5,26 @@ import { useOsInfo } from '../../hooks/useOsInfo';
interface Props {
action: HotkeyAction | null;
className?: string;
variant?: 'text' | 'with-bg';
}
export function HotKey({ action }: Props) {
const osinfo = useOsInfo();
export function HotKey({ action, className, variant }: Props) {
const osInfo = useOsInfo();
const label = useFormattedHotkey(action);
if (label === null || osinfo == null) {
if (label === null || osInfo == null) {
return null;
}
return (
<span className={classNames('text-sm text-gray-1000 text-opacity-disabled')}>{label}</span>
<span
className={classNames(
className,
variant === 'with-bg' && 'rounded border',
'text-sm text-gray-1000 text-opacity-disabled',
)}
>
{label}
</span>
);
}

View File

@@ -0,0 +1,11 @@
import type { HotkeyAction } from '../../hooks/useHotkey';
import { useHotKeyLabel } from '../../hooks/useHotkey';
interface Props {
action: HotkeyAction | null;
}
export function HotKeyLabel({ action }: Props) {
const label = useHotKeyLabel(action);
return <span>{label}</span>;
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
import type { HotkeyAction } from '../../hooks/useHotkey';
import { HotKey } from './HotKey';
import { HotKeyLabel } from './HotKeyLabel';
interface Props {
hotkeys: HotkeyAction[];
}
export const HotKeyList = ({ hotkeys }: Props) => {
return (
<div className="mx-auto h-full flex items-center text-gray-700 text-sm">
<div className="flex flex-col gap-1">
{hotkeys.map((hotkey) => (
<div key={hotkey} className="grid grid-cols-2">
<HotKeyLabel action={hotkey} />
<HotKey className="ml-auto" action={hotkey} />
</div>
))}
</div>
</div>
);
};

View File

@@ -8,6 +8,7 @@ import { ReactComponent as LeftPanelVisibleIcon } from '../../assets/icons/LeftP
const icons = {
archive: ReactIcons.ArchiveIcon,
camera: ReactIcons.CameraIcon,
chat: ReactIcons.ChatBubbleIcon,
check: ReactIcons.CheckIcon,
checkbox: ReactIcons.CheckboxIcon,
clock: ReactIcons.ClockIcon,

View File

@@ -426,7 +426,7 @@ const FormRow = memo(function FormRow({
size="sm"
title="Delete header"
onClick={!isLast ? handleDelete : undefined}
className="ml-0.5 group-hover:!opacity-100 focus-visible:!opacity-100"
className="ml-0.5 opacity-0 group-hover:!opacity-100 focus-visible:!opacity-100"
/>
</div>
);

View File

@@ -15,7 +15,7 @@ export function useCreateFolder() {
throw new Error("Cannot create folder when there's no active workspace");
}
patch.name = patch.name || 'New Folder';
patch.sortPriority = patch.sortPriority || Date.now();
patch.sortPriority = patch.sortPriority || -Date.now();
return invoke('create_folder', { workspaceId, ...patch });
},
onSettled: () => trackEvent('folder', 'create'),

View File

@@ -3,15 +3,16 @@ import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import type { HttpRequest } from '../lib/models';
import { useActiveEnvironmentId } from './useActiveEnvironmentId';
import { useActiveRequest } from './useActiveRequest';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useAppRoutes } from './useAppRoutes';
import { requestsQueryKey, useRequests } from './useRequests';
import { requestsQueryKey } from './useRequests';
export function useCreateRequest() {
const workspaceId = useActiveWorkspaceId();
const activeEnvironmentId = useActiveEnvironmentId();
const activeRequest = useActiveRequest();
const routes = useAppRoutes();
const requests = useRequests();
const queryClient = useQueryClient();
return useMutation<
@@ -23,7 +24,8 @@ export function useCreateRequest() {
if (workspaceId === null) {
throw new Error("Cannot create request when there's no active workspace");
}
patch.sortPriority = patch.sortPriority || maxSortPriority(requests) + 1000;
patch.sortPriority = patch.sortPriority || -Date.now();
patch.folderId = patch.folderId || activeRequest?.folderId;
return invoke('create_request', { workspaceId, name: '', ...patch });
},
onSettled: () => trackEvent('http_request', 'create'),
@@ -40,8 +42,3 @@ export function useCreateRequest() {
},
});
}
function maxSortPriority(requests: HttpRequest[]) {
if (requests.length === 0) return 1000;
return Math.max(...requests.map((r) => r.sortPriority));
}

View File

@@ -1,5 +1,6 @@
import type { OsType } from '@tauri-apps/api/os';
import { useEffect, useRef } from 'react';
import { debounce } from '../lib/debounce';
import { useOsInfo } from './useOsInfo';
export type HotkeyAction =
@@ -21,15 +22,26 @@ const hotkeys: Record<HotkeyAction, string[]> = {
'environmentEditor.toggle': ['CmdCtrl+e'],
};
export function useHotkey(action: HotkeyAction | null, callback: (e: KeyboardEvent) => void) {
interface Options {
enable?: boolean;
}
export function useHotkey(
action: HotkeyAction | null,
callback: (e: KeyboardEvent) => void,
options: Options = {},
) {
useAnyHotkey((hkAction, e) => {
if (hkAction === action) {
callback(e);
}
});
}, options);
}
export function useAnyHotkey(callback: (action: HotkeyAction, e: KeyboardEvent) => void) {
export function useAnyHotkey(
callback: (action: HotkeyAction, e: KeyboardEvent) => void,
options: Options,
) {
const currentKeys = useRef<Set<string>>(new Set());
const callbackRef = useRef(callback);
const osInfo = useOsInfo();
@@ -40,8 +52,16 @@ export function useAnyHotkey(callback: (action: HotkeyAction, e: KeyboardEvent)
}, [callback]);
useEffect(() => {
// Sometimes the keyup event doesn't fire, so we clear the keys after a timeout
const clearCurrentKeys = debounce(() => currentKeys.current.clear(), 1000);
const down = (e: KeyboardEvent) => {
if (options.enable === false) {
return;
}
currentKeys.current.add(normalizeKey(e.key, os));
for (const [hkAction, hkKeys] of Object.entries(hotkeys) as [HotkeyAction, string[]][]) {
for (const hkKey of hkKeys) {
const keys = hkKey.split('+');
@@ -56,8 +76,12 @@ export function useAnyHotkey(callback: (action: HotkeyAction, e: KeyboardEvent)
}
}
}
clearCurrentKeys();
};
const up = (e: KeyboardEvent) => {
if (options.enable === false) {
return;
}
currentKeys.current.delete(normalizeKey(e.key, os));
};
window.addEventListener('keydown', down);
@@ -66,7 +90,28 @@ export function useAnyHotkey(callback: (action: HotkeyAction, e: KeyboardEvent)
window.removeEventListener('keydown', down);
window.removeEventListener('keyup', up);
};
}, [os]);
}, [options.enable, os]);
}
export function useHotKeyLabel(action: HotkeyAction | null): string {
switch (action) {
case 'request.send':
return 'Send Request';
case 'request.create':
return 'New Request';
case 'request.duplicate':
return 'Duplicate Request';
case 'sidebar.toggle':
return 'Toggle Sidebar';
case 'sidebar.focus':
return 'Focus Sidebar';
case 'urlBar.focus':
return 'Focus URL';
case 'environmentEditor.toggle':
return 'Edit Environments';
default:
return 'Unknown';
}
}
export function useFormattedHotkey(action: HotkeyAction | null): string | null {