Files
yaak/src-web/components/CommandPalette.tsx
2024-06-07 22:52:23 -07:00

256 lines
7.8 KiB
TypeScript

import classNames from 'classnames';
import type { KeyboardEvent, ReactNode } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { useActiveRequestId } from '../hooks/useActiveRequestId';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useOpenWorkspace } from '../hooks/useOpenWorkspace';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
import { useRequests } from '../hooks/useRequests';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { Heading } from './core/Heading';
import { HttpMethodTag } from './core/HttpMethodTag';
import { Icon } from './core/Icon';
import { PlainInput } from './core/PlainInput';
import { HStack } from './core/Stacks';
interface CommandPaletteGroup {
key: string;
label: ReactNode;
items: CommandPaletteItem[];
}
type CommandPaletteItem = {
key: string;
onSelect: () => void;
} & ({ searchText: string; label: ReactNode } | { label: string });
const MAX_PER_GROUP = 4;
export function CommandPalette({ onClose }: { onClose: () => void }) {
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
const routes = useAppRoutes();
const activeEnvironmentId = useActiveEnvironmentId();
const activeRequestId = useActiveRequestId();
const activeWorkspaceId = useActiveWorkspaceId();
const workspaces = useWorkspaces();
const recentWorkspaces = useRecentWorkspaces();
const requests = useRequests();
const recentRequests = useRecentRequests();
const [command, setCommand] = useState<string>('');
const openWorkspace = useOpenWorkspace();
const sortedRequests = useMemo(() => {
return [...requests].sort((a, b) => {
const aRecentIndex = recentRequests.indexOf(a.id);
const bRecentIndex = recentRequests.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);
}
});
}, [recentRequests, requests]);
const sortedWorkspaces = useMemo(() => {
return [...workspaces].sort((a, b) => {
const aRecentIndex = recentWorkspaces.indexOf(a.id);
const bRecentIndex = recentWorkspaces.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);
}
});
}, [recentWorkspaces, workspaces]);
const groups = useMemo<CommandPaletteGroup[]>(() => {
const requestGroup: CommandPaletteGroup = {
key: 'requests',
label: 'Requests',
items: [],
};
for (const r of sortedRequests) {
if (r.id === activeRequestId) {
continue;
}
requestGroup.items.push({
key: `switch-request-${r.id}`,
searchText: `${r.method} ${r.name}`,
label: (
<HStack space={2}>
<HttpMethodTag className="text-fg-subtler" shortNames request={r} />
<div className="truncate">{fallbackRequestName(r)}</div>
</HStack>
),
onSelect: () => {
return routes.navigate('request', {
workspaceId: r.workspaceId,
requestId: r.id,
environmentId: activeEnvironmentId ?? undefined,
});
},
});
}
const workspaceGroup: CommandPaletteGroup = {
key: 'workspaces',
label: 'Workspaces',
items: [],
};
for (const w of sortedWorkspaces) {
if (w.id === activeWorkspaceId) {
continue;
}
workspaceGroup.items.push({
key: `switch-workspace-${w.id}`,
label: w.name,
onSelect: () => openWorkspace.mutate({ workspace: w, inNewWindow: false }),
});
}
return [requestGroup, workspaceGroup];
}, [
activeEnvironmentId,
activeRequestId,
activeWorkspaceId,
openWorkspace,
routes,
sortedRequests,
sortedWorkspaces,
]);
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;
})
.filter((g) => g.items.length > 0),
[command, groups],
);
const handleSelectAndClose = useCallback(
(cb: () => void) => {
onClose();
cb();
},
[onClose],
);
const { allItems, selectedItem } = useMemo(() => {
const allItems = filteredGroups.flatMap((g) => g.items);
let selectedItem = allItems.find((i) => i.key === selectedItemKey) ?? null;
if (selectedItem == null) {
selectedItem = allItems[0] ?? null;
}
return { selectedItem, allItems };
}, [filteredGroups, selectedItemKey]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
const index = allItems.findIndex((v) => v.key === selectedItem?.key);
if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) {
const next = allItems[index + 1];
setSelectedItemKey(next?.key ?? null);
} else if (e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'k')) {
const prev = allItems[index - 1];
setSelectedItemKey(prev?.key ?? null);
} else if (e.key === 'Enter') {
const selected = allItems[index];
setSelectedItemKey(selected?.key ?? null);
if (selected) {
handleSelectAndClose(selected.onSelect);
}
}
},
[allItems, handleSelectAndClose, selectedItem?.key],
);
return (
<div className="h-full max-h-[20rem] w-[400px] grid grid-rows-[auto_minmax(0,1fr)]">
<div className="px-2 py-2 w-full">
<PlainInput
hideLabel
leftSlot={
<div className="h-md w-10 flex justify-center items-center">
<Icon icon="search" className="text-fg-subtle" />
</div>
}
name="command"
label="Command"
placeholder="Search or type a command"
className="font-sans !text-base"
defaultValue={command}
onChange={setCommand}
onKeyDownCapture={handleKeyDown}
/>
</div>
<div className="h-full px-1.5 overflow-y-auto pb-1">
{filteredGroups.map((g) => (
<div key={g.key} className="mb-1.5">
<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) => (
<CommandPaletteItem
active={v.key === selectedItem?.key}
key={v.key}
onClick={() => handleSelectAndClose(v.onSelect)}
>
{v.label}
</CommandPaletteItem>
))}
</div>
))}
</div>
</div>
);
}
function CommandPaletteItem({
children,
active,
onClick,
}: {
children: ReactNode;
active: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
tabIndex={active ? undefined : -1}
className={classNames(
'w-full h-sm flex items-center rounded px-1.5',
'hover:text-fg',
active && 'bg-background-highlight-secondary text-fg',
!active && 'text-fg-subtle',
)}
>
<span className="truncate">{children}</span>
</button>
);
}