mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-17 23:13:51 +01:00
Cmd Palette Improvements (#50)
- Fuzzy matching - Show hotkeys - Add actions
This commit is contained in:
@@ -1,21 +1,40 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import classNames from 'classnames';
|
||||
import { search } from 'fast-fuzzy';
|
||||
import type { KeyboardEvent, ReactNode } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useActiveCookieJar } from '../hooks/useActiveCookieJar';
|
||||
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
|
||||
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
|
||||
import { useActiveRequestId } from '../hooks/useActiveRequestId';
|
||||
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
|
||||
import { useAppRoutes } from '../hooks/useAppRoutes';
|
||||
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
|
||||
import { useCreateGrpcRequest } from '../hooks/useCreateGrpcRequest';
|
||||
import { useCreateHttpRequest } from '../hooks/useCreateHttpRequest';
|
||||
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
|
||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||
import { useEnvironments } from '../hooks/useEnvironments';
|
||||
import type { HotkeyAction } from '../hooks/useHotKey';
|
||||
import { useHotKey } from '../hooks/useHotKey';
|
||||
import { useOpenWorkspace } from '../hooks/useOpenWorkspace';
|
||||
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
|
||||
import { useRecentRequests } from '../hooks/useRecentRequests';
|
||||
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
|
||||
import { useRequests } from '../hooks/useRequests';
|
||||
import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
||||
import { useWorkspaces } from '../hooks/useWorkspaces';
|
||||
import { fallbackRequestName } from '../lib/fallbackRequestName';
|
||||
import { CookieDialog } from './CookieDialog';
|
||||
import { Button } from './core/Button';
|
||||
import { Heading } from './core/Heading';
|
||||
import { HotKey } from './core/HotKey';
|
||||
import { HttpMethodTag } from './core/HttpMethodTag';
|
||||
import { Icon } from './core/Icon';
|
||||
import { PlainInput } from './core/PlainInput';
|
||||
import { HStack } from './core/Stacks';
|
||||
import { useDialog } from './DialogContext';
|
||||
import { EnvironmentEditDialog } from './EnvironmentEditDialog';
|
||||
|
||||
interface CommandPaletteGroup {
|
||||
key: string;
|
||||
@@ -26,22 +45,120 @@ interface CommandPaletteGroup {
|
||||
type CommandPaletteItem = {
|
||||
key: string;
|
||||
onSelect: () => void;
|
||||
action?: HotkeyAction;
|
||||
} & ({ searchText: string; label: ReactNode } | { label: string });
|
||||
|
||||
const MAX_PER_GROUP = 4;
|
||||
const MAX_PER_GROUP = 8;
|
||||
|
||||
export function CommandPalette({ onClose }: { onClose: () => void }) {
|
||||
const [command, setCommand] = useDebouncedState<string>('', 150);
|
||||
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
|
||||
const routes = useAppRoutes();
|
||||
const activeEnvironmentId = useActiveEnvironmentId();
|
||||
const activeRequestId = useActiveRequestId();
|
||||
const activeWorkspaceId = useActiveWorkspaceId();
|
||||
const active = useActiveWorkspaceId();
|
||||
const workspaces = useWorkspaces();
|
||||
const environments = useEnvironments();
|
||||
const recentEnvironments = useRecentEnvironments();
|
||||
const recentWorkspaces = useRecentWorkspaces();
|
||||
const requests = useRequests();
|
||||
const recentRequests = useRecentRequests();
|
||||
const [command, setCommand] = useState<string>('');
|
||||
const openWorkspace = useOpenWorkspace();
|
||||
const createWorkspace = useCreateWorkspace();
|
||||
const createHttpRequest = useCreateHttpRequest();
|
||||
const { activeCookieJar } = useActiveCookieJar();
|
||||
const createGrpcRequest = useCreateGrpcRequest();
|
||||
const createEnvironment = useCreateEnvironment();
|
||||
const dialog = useDialog();
|
||||
const workspaceId = useActiveWorkspaceId();
|
||||
const activeEnvironment = useActiveEnvironment();
|
||||
const [, setSidebarHidden] = useSidebarHidden();
|
||||
|
||||
const workspaceCommands = useMemo<CommandPaletteItem[]>(() => {
|
||||
const commands: CommandPaletteItem[] = [
|
||||
{
|
||||
key: 'settings.open',
|
||||
label: 'Open Settings',
|
||||
action: 'settings.show',
|
||||
onSelect: async () => {
|
||||
if (workspaceId == null) return;
|
||||
await invoke('cmd_new_nested_window', {
|
||||
url: routes.paths.workspaceSettings({ workspaceId }),
|
||||
label: 'settings',
|
||||
title: 'Yaak Settings',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'app.create',
|
||||
label: 'Create Workspace',
|
||||
onSelect: createWorkspace.mutate,
|
||||
},
|
||||
{
|
||||
key: 'http_request.create',
|
||||
label: 'Create HTTP Request',
|
||||
onSelect: () => createHttpRequest.mutate({}),
|
||||
},
|
||||
{
|
||||
key: 'cookies.show',
|
||||
label: 'Show Cookies',
|
||||
onSelect: async () => {
|
||||
dialog.show({
|
||||
id: 'cookies',
|
||||
title: 'Manage Cookies',
|
||||
size: 'full',
|
||||
render: () => <CookieDialog cookieJarId={activeCookieJar?.id ?? null} />,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'grpc_request.create',
|
||||
label: 'Create GRPC Request',
|
||||
onSelect: () => createGrpcRequest.mutate({}),
|
||||
},
|
||||
{
|
||||
key: 'environment.edit',
|
||||
label: 'Edit Environment',
|
||||
action: 'environmentEditor.toggle',
|
||||
onSelect: () => {
|
||||
dialog.toggle({
|
||||
id: 'environment-editor',
|
||||
noPadding: true,
|
||||
size: 'lg',
|
||||
className: 'h-[80vh]',
|
||||
render: () => <EnvironmentEditDialog initialEnvironment={activeEnvironment} />,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'environment.create',
|
||||
label: 'Create Environment',
|
||||
onSelect: createEnvironment.mutate,
|
||||
},
|
||||
{
|
||||
key: 'sidebar.toggle',
|
||||
label: 'Toggle Sidebar',
|
||||
action: 'sidebar.focus',
|
||||
onSelect: () => setSidebarHidden((h) => !h),
|
||||
},
|
||||
];
|
||||
return commands.sort((a, b) =>
|
||||
('searchText' in a ? a.searchText : a.label).localeCompare(
|
||||
'searchText' in b ? b.searchText : b.label,
|
||||
),
|
||||
);
|
||||
}, [
|
||||
activeCookieJar,
|
||||
activeEnvironment,
|
||||
createEnvironment.mutate,
|
||||
createGrpcRequest,
|
||||
createHttpRequest,
|
||||
createWorkspace.mutate,
|
||||
dialog,
|
||||
routes.paths,
|
||||
setSidebarHidden,
|
||||
workspaceId,
|
||||
]);
|
||||
|
||||
const sortedRequests = useMemo(() => {
|
||||
return [...requests].sort((a, b) => {
|
||||
@@ -60,6 +177,23 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
|
||||
});
|
||||
}, [recentRequests, requests]);
|
||||
|
||||
const sortedEnvironments = useMemo(() => {
|
||||
return [...environments].sort((a, b) => {
|
||||
const aRecentIndex = recentEnvironments.indexOf(a.id);
|
||||
const bRecentIndex = recentEnvironments.indexOf(b.id);
|
||||
|
||||
if (aRecentIndex >= 0 && bRecentIndex >= 0) {
|
||||
return aRecentIndex - bRecentIndex;
|
||||
} else if (aRecentIndex >= 0 && bRecentIndex === -1) {
|
||||
return -1;
|
||||
} else if (aRecentIndex === -1 && bRecentIndex >= 0) {
|
||||
return 1;
|
||||
} else {
|
||||
return a.createdAt.localeCompare(b.createdAt);
|
||||
}
|
||||
});
|
||||
}, [environments, recentEnvironments]);
|
||||
|
||||
const sortedWorkspaces = useMemo(() => {
|
||||
return [...workspaces].sort((a, b) => {
|
||||
const aRecentIndex = recentWorkspaces.indexOf(a.id);
|
||||
@@ -78,6 +212,12 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
|
||||
}, [recentWorkspaces, workspaces]);
|
||||
|
||||
const groups = useMemo<CommandPaletteGroup[]>(() => {
|
||||
const actionsGroup: CommandPaletteGroup = {
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
items: workspaceCommands,
|
||||
};
|
||||
|
||||
const requestGroup: CommandPaletteGroup = {
|
||||
key: 'requests',
|
||||
label: 'Requests',
|
||||
@@ -91,10 +231,10 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
|
||||
|
||||
requestGroup.items.push({
|
||||
key: `switch-request-${r.id}`,
|
||||
searchText: `${r.method} ${r.name}`,
|
||||
searchText: fallbackRequestName(r),
|
||||
label: (
|
||||
<HStack space={2}>
|
||||
<HttpMethodTag className="text-fg-subtler" shortNames request={r} />
|
||||
<HttpMethodTag className="text-fg-subtler" request={r} />
|
||||
<div className="truncate">{fallbackRequestName(r)}</div>
|
||||
</HStack>
|
||||
),
|
||||
@@ -108,6 +248,23 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
|
||||
});
|
||||
}
|
||||
|
||||
const environmentGroup: CommandPaletteGroup = {
|
||||
key: 'environments',
|
||||
label: 'Environments',
|
||||
items: [],
|
||||
};
|
||||
|
||||
for (const e of sortedEnvironments) {
|
||||
if (e.id === activeEnvironment?.id) {
|
||||
continue;
|
||||
}
|
||||
environmentGroup.items.push({
|
||||
key: `switch-environment-${e.id}`,
|
||||
label: e.name,
|
||||
onSelect: () => routes.setEnvironment(e),
|
||||
});
|
||||
}
|
||||
|
||||
const workspaceGroup: CommandPaletteGroup = {
|
||||
key: 'workspaces',
|
||||
label: 'Workspaces',
|
||||
@@ -115,7 +272,7 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
|
||||
};
|
||||
|
||||
for (const w of sortedWorkspaces) {
|
||||
if (w.id === activeWorkspaceId) {
|
||||
if (w.id === active) {
|
||||
continue;
|
||||
}
|
||||
workspaceGroup.items.push({
|
||||
@@ -125,30 +282,44 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
|
||||
});
|
||||
}
|
||||
|
||||
return [requestGroup, workspaceGroup];
|
||||
return [actionsGroup, requestGroup, environmentGroup, workspaceGroup];
|
||||
}, [
|
||||
activeEnvironmentId,
|
||||
activeRequestId,
|
||||
activeWorkspaceId,
|
||||
openWorkspace,
|
||||
routes,
|
||||
workspaceCommands,
|
||||
sortedRequests,
|
||||
activeRequestId,
|
||||
routes,
|
||||
activeEnvironmentId,
|
||||
sortedEnvironments,
|
||||
activeEnvironment?.id,
|
||||
sortedWorkspaces,
|
||||
active,
|
||||
openWorkspace,
|
||||
]);
|
||||
|
||||
const filteredGroups = useMemo(
|
||||
() =>
|
||||
groups
|
||||
.map((g) => {
|
||||
g.items = g.items.filter((v) => {
|
||||
const s = 'searchText' in v ? v.searchText : v.label;
|
||||
return s.toLowerCase().includes(command.toLowerCase());
|
||||
});
|
||||
return g;
|
||||
const allItems = useMemo(() => groups.flatMap((g) => g.items), [groups]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedItemKey(null);
|
||||
}, [command]);
|
||||
|
||||
const { filteredGroups, filteredAllItems } = useMemo(() => {
|
||||
const result = command
|
||||
? search(command, allItems, {
|
||||
threshold: 0.4,
|
||||
keySelector: (v) => ('searchText' in v ? v.searchText : v.label),
|
||||
})
|
||||
.filter((g) => g.items.length > 0),
|
||||
[command, groups],
|
||||
);
|
||||
: allItems;
|
||||
|
||||
const filteredGroups = groups
|
||||
.map((g) => {
|
||||
g.items = result.filter((i) => g.items.includes(i)).slice(0, MAX_PER_GROUP);
|
||||
return g;
|
||||
})
|
||||
.filter((g) => g.items.length > 0);
|
||||
|
||||
const filteredAllItems = filteredGroups.flatMap((g) => g.items);
|
||||
return { filteredAllItems, filteredGroups };
|
||||
}, [allItems, command, groups]);
|
||||
|
||||
const handleSelectAndClose = useCallback(
|
||||
(cb: () => void) => {
|
||||
@@ -158,38 +329,37 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const { allItems, selectedItem } = useMemo(() => {
|
||||
const allItems = filteredGroups.flatMap((g) => g.items);
|
||||
let selectedItem = allItems.find((i) => i.key === selectedItemKey) ?? null;
|
||||
const selectedItem = useMemo(() => {
|
||||
let selectedItem = filteredAllItems.find((i) => i.key === selectedItemKey) ?? null;
|
||||
if (selectedItem == null) {
|
||||
selectedItem = allItems[0] ?? null;
|
||||
selectedItem = filteredAllItems[0] ?? null;
|
||||
}
|
||||
return { selectedItem, allItems };
|
||||
}, [filteredGroups, selectedItemKey]);
|
||||
return selectedItem;
|
||||
}, [filteredAllItems, selectedItemKey]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
const index = allItems.findIndex((v) => v.key === selectedItem?.key);
|
||||
const index = filteredAllItems.findIndex((v) => v.key === selectedItem?.key);
|
||||
|
||||
if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) {
|
||||
const next = allItems[index + 1];
|
||||
const next = filteredAllItems[index + 1] ?? filteredAllItems[0];
|
||||
setSelectedItemKey(next?.key ?? null);
|
||||
} else if (e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'k')) {
|
||||
const prev = allItems[index - 1];
|
||||
const prev = filteredAllItems[index - 1] ?? filteredAllItems[filteredAllItems.length - 1];
|
||||
setSelectedItemKey(prev?.key ?? null);
|
||||
} else if (e.key === 'Enter') {
|
||||
const selected = allItems[index];
|
||||
const selected = filteredAllItems[index];
|
||||
setSelectedItemKey(selected?.key ?? null);
|
||||
if (selected) {
|
||||
handleSelectAndClose(selected.onSelect);
|
||||
}
|
||||
}
|
||||
},
|
||||
[allItems, handleSelectAndClose, selectedItem?.key],
|
||||
[filteredAllItems, handleSelectAndClose, selectedItem?.key],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full max-h-[20rem] w-[400px] grid grid-rows-[auto_minmax(0,1fr)]">
|
||||
<div className="h-full w-[400px] grid grid-rows-[auto_minmax(0,1fr)] overflow-hidden">
|
||||
<div className="px-2 py-2 w-full">
|
||||
<PlainInput
|
||||
hideLabel
|
||||
@@ -209,15 +379,18 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
<div className="h-full px-1.5 overflow-y-auto pb-1">
|
||||
{filteredGroups.map((g) => (
|
||||
<div key={g.key} className="mb-1.5">
|
||||
<div key={g.key} className="mb-1.5 w-full">
|
||||
<Heading size={2} className="!text-xs uppercase px-1.5 h-sm flex items-center">
|
||||
{g.label}
|
||||
</Heading>
|
||||
{g.items.slice(0, MAX_PER_GROUP).map((v) => (
|
||||
{g.items.map((v) => (
|
||||
<CommandPaletteItem
|
||||
active={v.key === selectedItem?.key}
|
||||
key={v.key}
|
||||
onClick={() => handleSelectAndClose(v.onSelect)}
|
||||
rightSlot={
|
||||
v.action && <CommandPaletteAction action={v.action} onAction={v.onSelect} />
|
||||
}
|
||||
>
|
||||
{v.label}
|
||||
</CommandPaletteItem>
|
||||
@@ -233,15 +406,20 @@ function CommandPaletteItem({
|
||||
children,
|
||||
active,
|
||||
onClick,
|
||||
rightSlot,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
rightSlot?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
onClick={onClick}
|
||||
tabIndex={active ? undefined : -1}
|
||||
rightSlot={rightSlot}
|
||||
color="custom"
|
||||
justify="start"
|
||||
className={classNames(
|
||||
'w-full h-sm flex items-center rounded px-1.5',
|
||||
'hover:text-fg',
|
||||
@@ -250,6 +428,17 @@ function CommandPaletteItem({
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{children}</span>
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandPaletteAction({
|
||||
action,
|
||||
onAction,
|
||||
}: {
|
||||
action: HotkeyAction;
|
||||
onAction: () => void;
|
||||
}) {
|
||||
useHotKey(action, onAction);
|
||||
return <HotKey className="ml-auto" action={action} />;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useCommandPalette } from '../hooks/useCommandPalette';
|
||||
import { cookieJarsQueryKey } from '../hooks/useCookieJars';
|
||||
import { environmentsQueryKey } from '../hooks/useEnvironments';
|
||||
import { foldersQueryKey } from '../hooks/useFolders';
|
||||
import { useGlobalCommands } from '../hooks/useGlobalCommands';
|
||||
import { grpcConnectionsQueryKey } from '../hooks/useGrpcConnections';
|
||||
import { grpcEventsQueryKey } from '../hooks/useGrpcEvents';
|
||||
import { grpcRequestsQueryKey } from '../hooks/useGrpcRequests';
|
||||
@@ -42,7 +41,6 @@ export function GlobalHooks() {
|
||||
|
||||
// Other useful things
|
||||
useSyncThemeToDocument();
|
||||
useGlobalCommands();
|
||||
useCommandPalette();
|
||||
useNotificationToast();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import classNames from 'classnames';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
|
||||
import { useCommand } from '../hooks/useCommands';
|
||||
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
|
||||
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
|
||||
import { useOpenWorkspace } from '../hooks/useOpenWorkspace';
|
||||
import { usePrompt } from '../hooks/usePrompt';
|
||||
@@ -28,7 +28,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
||||
const activeWorkspaceId = activeWorkspace?.id ?? null;
|
||||
const updateWorkspace = useUpdateWorkspace(activeWorkspaceId);
|
||||
const deleteWorkspace = useDeleteWorkspace(activeWorkspace);
|
||||
const createWorkspace = useCommand('workspace.create');
|
||||
const createWorkspace = useCreateWorkspace();
|
||||
const dialog = useDialog();
|
||||
const prompt = usePrompt();
|
||||
const settings = useSettings();
|
||||
@@ -101,7 +101,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
||||
key: 'create-workspace',
|
||||
label: 'New Workspace',
|
||||
leftSlot: <Icon icon="plus" />,
|
||||
onSelect: () => createWorkspace.mutate({}),
|
||||
onSelect: createWorkspace.mutate,
|
||||
},
|
||||
];
|
||||
}, [
|
||||
|
||||
@@ -54,7 +54,7 @@ export function Dialog({
|
||||
<Overlay open={open} onClose={onClose} portalName="dialog">
|
||||
<div
|
||||
className={classNames(
|
||||
'x-theme-dialog absolute inset-0 flex flex-col items-center pointer-events-none my-5',
|
||||
'x-theme-dialog absolute inset-0 flex flex-col items-center pointer-events-none',
|
||||
vAlign === 'top' && 'justify-start',
|
||||
vAlign === 'center' && 'justify-center',
|
||||
)}
|
||||
@@ -74,12 +74,12 @@ export function Dialog({
|
||||
'relative bg-background pointer-events-auto',
|
||||
'rounded-lg',
|
||||
'border border-background-highlight-secondary shadow-lg shadow-[rgba(0,0,0,0.1)]',
|
||||
'max-w-[calc(100vw-5rem)] max-h-[calc(100vh-6rem)]',
|
||||
'max-w-[calc(100vw-5rem)] max-h-[calc(100vh-4rem)]',
|
||||
size === 'sm' && 'w-[28rem] max-h-[80vh]',
|
||||
size === 'md' && 'w-[45rem] max-h-[80vh]',
|
||||
size === 'lg' && 'w-[65rem] max-h-[80vh]',
|
||||
size === 'full' && 'w-[100vw] h-[100vh]',
|
||||
size === 'dynamic' && 'min-w-[20rem] max-w-[80vw] max-h-[80vh]',
|
||||
size === 'dynamic' && 'min-w-[20rem] max-w-[100vw] w-full mt-8',
|
||||
)}
|
||||
>
|
||||
{title ? (
|
||||
|
||||
Reference in New Issue
Block a user