Start of command palette

This commit is contained in:
Gregory Schier
2024-03-18 17:09:01 -07:00
parent 17423f8c54
commit a5dd3beb73
13 changed files with 190 additions and 73 deletions

View File

@@ -0,0 +1,59 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { useCallback, useState } from 'react';
import { useRequests } from '../hooks/useRequests';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { Input } from './core/Input';
export function CommandPalette() {
const [selectedIndex, setSelectedIndex] = useState<number>(0);
const workspaces = useWorkspaces();
const requests = useRequests();
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
setSelectedIndex((prev) => prev + 1);
} else if (e.key === 'ArrowUp') {
setSelectedIndex((prev) => prev - 1);
}
}, []);
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<div className="px-2 py-2 w-full">
<Input
hideLabel
name="command"
label="Command"
placeholder="Type a command"
onKeyDown={handleKeyDown}
/>
</div>
<div className="h-full px-1.5 overflow-y-auto">
{requests.map((r, i) => (
<CommandPaletteItem active={i === selectedIndex} key={r.id}>
Switch Request {fallbackRequestName(r)}
</CommandPaletteItem>
))}
{workspaces.map((w, i) => (
<CommandPaletteItem active={i === selectedIndex} key={w.id}>
Switch Workspace {w.name}
</CommandPaletteItem>
))}
</div>
</div>
);
}
function CommandPaletteItem({ children, active }: { children: ReactNode; active: boolean }) {
return (
<div
className={classNames(
'h-xs flex items-center rounded px-1.5 text-gray-600',
active && 'bg-highlightSecondary text-gray-800',
)}
>
{children}
</div>
);
}

View File

@@ -6,7 +6,7 @@ import { Dialog } from './core/Dialog';
type DialogEntry = {
id: string;
render: ({ hide }: { hide: () => void }) => React.ReactNode;
} & Pick<DialogProps, 'title' | 'description' | 'hideX' | 'className' | 'size' | 'noPadding'>;
} & Omit<DialogProps, 'onClose' | 'open' | 'children'>;
interface State {
dialogs: DialogEntry[];

View File

@@ -2,6 +2,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { appWindow } from '@tauri-apps/api/window';
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useCommandPalette } from '../hooks/useCommandPalette';
import { cookieJarsQueryKey } from '../hooks/useCookieJars';
import { useGlobalCommands } from '../hooks/useGlobalCommands';
import { grpcConnectionsQueryKey } from '../hooks/useGrpcConnections';
@@ -35,6 +36,7 @@ export function GlobalHooks() {
useSyncAppearance();
useSyncWindowTitle();
useGlobalCommands();
useCommandPalette();
const queryClient = useQueryClient();
const { wasUpdatedExternally } = useRequestUpdateKey(null);

View File

@@ -122,7 +122,7 @@ export function GrpcConnectionSetupPane({
const handleSend = useCallback(async () => {
if (activeRequest == null) return;
onSend({ message: activeRequest.message });
}, [activeRequest, onGo]);
}, [activeRequest, onSend]);
const tabs: TabItem[] = useMemo(
() => [

View File

@@ -1,14 +1,13 @@
import classNames from 'classnames';
import { useMemo, useRef } from 'react';
import { useKey, useKeyPressEvent } from 'react-use';
import { useKeyPressEvent } from 'react-use';
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useGrpcRequests } from '../hooks/useGrpcRequests';
import { useHotKey } from '../hooks/useHotKey';
import { useHttpRequests } from '../hooks/useHttpRequests';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRequests } from '../hooks/useRequests';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
@@ -21,20 +20,10 @@ export function RecentRequestsDropdown({ className }: Pick<ButtonProps, 'classNa
const activeRequest = useActiveRequest();
const activeWorkspaceId = useActiveWorkspaceId();
const activeEnvironment = useActiveEnvironment();
const httpRequests = useHttpRequests();
const grpcRequests = useGrpcRequests();
const routes = useAppRoutes();
const allRecentRequestIds = useRecentRequests();
const recentRequestIds = useMemo(() => allRecentRequestIds.slice(1), [allRecentRequestIds]);
const requests = useMemo(() => [...httpRequests, ...grpcRequests], [httpRequests, grpcRequests]);
// Toggle the menu on Cmd+k
useKey('k', (e) => {
if (e.metaKey) {
e.preventDefault();
dropdownRef.current?.toggle();
}
});
const requests = useRequests();
// Handle key-up
useKeyPressEvent('Control', undefined, () => {
@@ -42,16 +31,20 @@ export function RecentRequestsDropdown({ className }: Pick<ButtonProps, 'classNa
dropdownRef.current?.select?.();
});
useHotKey('requestSwitcher.prev', () => {
useHotKey('request_switcher.prev', () => {
if (!dropdownRef.current?.isOpen) dropdownRef.current?.open();
dropdownRef.current?.next?.();
});
useHotKey('requestSwitcher.next', () => {
useHotKey('request_switcher.next', () => {
if (!dropdownRef.current?.isOpen) dropdownRef.current?.open();
dropdownRef.current?.prev?.();
});
useHotKey('request_switcher.toggle', () => {
dropdownRef.current?.toggle();
});
const items = useMemo<DropdownItem[]>(() => {
if (activeWorkspaceId === null) return [];

View File

@@ -13,6 +13,11 @@ export function RedirectToLatestWorkspace() {
const recentWorkspaces = useRecentWorkspaces();
useEffect(() => {
if (workspaces.length === 0) {
console.log('No workspaces found to redirect to. Skipping.');
return;
}
(async function () {
const workspaceId = recentWorkspaces[0] ?? workspaces[0]?.id ?? 'n/a';
const environmentId = (await getRecentEnvironments(workspaceId))[0];

View File

@@ -61,6 +61,25 @@ export const RequestPane = memo(function RequestPane({
const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest.id ?? null);
const contentType = useContentTypeFromHeaders(activeRequest.headers);
const handleContentTypeChange = useCallback(
async (contentType: string | null) => {
const headers = activeRequest.headers.filter((h) => h.name.toLowerCase() !== 'content-type');
if (contentType != null) {
headers.push({
name: 'Content-Type',
value: contentType,
enabled: true,
});
}
await updateRequest.mutateAsync({ headers });
// Force update header editor so any changed headers are reflected
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
},
[activeRequest.headers, updateRequest],
);
const tabs: TabItem[] = useMemo(
() => [
{
@@ -153,7 +172,15 @@ export const RequestPane = memo(function RequestPane({
},
},
],
[activeRequest, updateRequest],
[
activeRequest.authentication,
activeRequest.authenticationType,
activeRequest.bodyType,
activeRequest.headers,
activeRequest.urlParameters,
handleContentTypeChange,
updateRequest,
],
);
const handleBodyChange = useCallback(
@@ -161,24 +188,6 @@ export const RequestPane = memo(function RequestPane({
[updateRequest],
);
const handleContentTypeChange = useCallback(
async (contentType: string | null) => {
const headers = activeRequest.headers.filter((h) => h.name.toLowerCase() !== 'content-type');
if (contentType != null) {
headers.push({
name: 'Content-Type',
value: contentType,
enabled: true,
});
}
await updateRequest.mutateAsync({ headers });
// Force update header editor so any changed headers are reflected
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
},
[activeRequest.headers, updateRequest],
);
const handleBinaryFileChange = useCallback(
(body: HttpRequest['body']) => {
updateRequest.mutate({ body });

View File

@@ -17,6 +17,7 @@ export interface DialogProps {
size?: 'sm' | 'md' | 'lg' | 'full' | 'dynamic';
hideX?: boolean;
noPadding?: boolean;
noScroll?: boolean;
}
export function Dialog({
@@ -29,6 +30,7 @@ export function Dialog({
description,
hideX,
noPadding,
noScroll,
}: DialogProps) {
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
const descriptionId = useMemo(
@@ -60,7 +62,7 @@ export function Dialog({
animate={{ top: 0, scale: 1 }}
className={classNames(
className,
'grid grid-rows-[auto_minmax(0,1fr)]',
'h-full grid grid-rows-[auto_auto_minmax(0,1fr)]',
'relative bg-gray-50 pointer-events-auto',
'rounded-lg',
'dark:border border-highlight shadow shadow-black/10',
@@ -79,15 +81,20 @@ export function Dialog({
) : (
<span />
)}
{description && (
{description ? (
<p className="px-6 text-gray-700" id={descriptionId}>
{description}
</p>
) : (
<span />
)}
<div
className={classNames(
'h-full w-full grid grid-cols-[minmax(0,1fr)] overflow-y-auto',
'h-full w-full grid grid-cols-[minmax(0,1fr)]',
!noPadding && 'px-6 py-2',
!noScroll && 'overflow-y-auto',
)}
>
{children}

View File

@@ -1,10 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import * as app from '@tauri-apps/api/app';
import * as path from '@tauri-apps/api/path';
import { invoke } from '@tauri-apps/api';
export function useAppInfo() {
return useQuery(['appInfo'], async () => {
const [version, appDataDir] = await Promise.all([app.getVersion(), path.appDataDir()]);
return { version, appDataDir };
return (await invoke('cmd_metadata')) as {
isDev: boolean;
version: string;
name: string;
appDataDir: string;
};
});
}

View File

@@ -0,0 +1,24 @@
import { CommandPalette } from '../components/CommandPalette';
import { useDialog } from '../components/DialogContext';
import { useAppInfo } from './useAppInfo';
import { useHotKey } from './useHotKey';
export function useCommandPalette() {
const dialog = useDialog();
const appInfo = useAppInfo();
useHotKey('command_palette.toggle', () => {
// Disabled in production for now
if (!appInfo.data?.isDev) {
return;
}
dialog.toggle({
id: 'command_palette',
size: 'md',
hideX: true,
noPadding: true,
noScroll: true,
render: () => <CommandPalette />,
});
});
}

View File

@@ -13,12 +13,14 @@ export type HotkeyAction =
| 'http_request.create'
| 'http_request.duplicate'
| 'http_request.send'
| 'requestSwitcher.next'
| 'requestSwitcher.prev'
| 'request_switcher.next'
| 'request_switcher.prev'
| 'request_switcher.toggle'
| 'settings.show'
| 'sidebar.focus'
| 'sidebar.toggle'
| 'urlBar.focus';
| 'urlBar.focus'
| 'command_palette.toggle';
const hotkeys: Record<HotkeyAction, string[]> = {
'environmentEditor.toggle': ['CmdCtrl+Shift+e'],
@@ -27,12 +29,14 @@ const hotkeys: Record<HotkeyAction, string[]> = {
'http_request.create': ['CmdCtrl+n'],
'http_request.duplicate': ['CmdCtrl+d'],
'http_request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'],
'requestSwitcher.next': ['Control+Shift+Tab'],
'requestSwitcher.prev': ['Control+Tab'],
'request_switcher.next': ['Control+Shift+Tab'],
'request_switcher.prev': ['Control+Tab'],
'request_switcher.toggle': ['CmdCtrl+p'],
'settings.show': ['CmdCtrl+,'],
'sidebar.focus': ['CmdCtrl+1'],
'sidebar.toggle': ['CmdCtrl+b'],
'urlBar.focus': ['CmdCtrl+l'],
'command_palette.toggle': ['CmdCtrl+k'],
};
const hotkeyLabels: Record<HotkeyAction, string> = {
@@ -42,12 +46,14 @@ const hotkeyLabels: Record<HotkeyAction, string> = {
'http_request.create': 'New Request',
'http_request.duplicate': 'Duplicate Request',
'http_request.send': 'Send Request',
'requestSwitcher.next': 'Go To Previous Request',
'requestSwitcher.prev': 'Go To Next Request',
'request_switcher.next': 'Go To Previous Request',
'request_switcher.prev': 'Go To Next Request',
'request_switcher.toggle': 'Toggle Request Switcher',
'settings.show': 'Open Settings',
'sidebar.focus': 'Focus Sidebar',
'sidebar.toggle': 'Toggle Sidebar',
'urlBar.focus': 'Focus URL',
'command_palette.toggle': 'Toggle Command Palette',
};
export const hotkeyActions: HotkeyAction[] = Object.keys(hotkeys) as (keyof typeof hotkeys)[];
@@ -135,7 +141,7 @@ export function useHotKey(
document.removeEventListener('keydown', down, { capture: true });
document.removeEventListener('keyup', up, { capture: true });
};
}, [options.enable, os]);
}, [action, options.enable, os]);
}
export function useHotKeyLabel(action: HotkeyAction): string {