Add command palette (#46)

This PR finished the initial PoC command palette. It currently only
supports switching between requests and workspaces, but can easily be
extended for more.
This commit is contained in:
Gregory Schier
2024-06-07 21:59:57 -07:00
committed by GitHub
parent 5e058af03e
commit b0e4ece278
7 changed files with 175 additions and 79 deletions

View File

@@ -1,28 +1,86 @@
import classNames from 'classnames'; import classNames from 'classnames';
import type { ReactNode } from 'react'; import type { KeyboardEvent, ReactNode } from 'react';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId'; import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { useAppRoutes } from '../hooks/useAppRoutes'; import { useAppRoutes } from '../hooks/useAppRoutes';
import { getRecentEnvironments } from '../hooks/useRecentEnvironments'; import { useOpenWorkspace } from '../hooks/useOpenWorkspace';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
import { useRequests } from '../hooks/useRequests'; import { useRequests } from '../hooks/useRequests';
import { useWorkspaces } from '../hooks/useWorkspaces'; import { useWorkspaces } from '../hooks/useWorkspaces';
import { fallbackRequestName } from '../lib/fallbackRequestName'; import { fallbackRequestName } from '../lib/fallbackRequestName';
import { Input } from './core/Input'; import { Heading } from './core/Heading';
import { Icon } from './core/Icon';
import { PlainInput } from './core/PlainInput';
interface CommandPaletteGroup {
key: string;
label: string;
items: CommandPaletteItem[];
}
interface CommandPaletteItem {
key: string;
label: string;
onSelect: () => void;
}
export function CommandPalette({ onClose }: { onClose: () => void }) { export function CommandPalette({ onClose }: { onClose: () => void }) {
const [selectedIndex, setSelectedIndex] = useState<number>(0); const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
const routes = useAppRoutes(); const routes = useAppRoutes();
const activeEnvironmentId = useActiveEnvironmentId(); const activeEnvironmentId = useActiveEnvironmentId();
const workspaces = useWorkspaces(); const workspaces = useWorkspaces();
const recentWorkspaces = useRecentWorkspaces();
const requests = useRequests(); const requests = useRequests();
const recentRequests = useRecentRequests();
const [command, setCommand] = useState<string>(''); const [command, setCommand] = useState<string>('');
const openWorkspace = useOpenWorkspace();
const items = useMemo<{ label: string; onSelect: () => void; key: string }[]>(() => { const sortedRequests = useMemo(() => {
const items = []; return [...requests].sort((a, b) => {
for (const r of requests) { const aRecentIndex = recentRequests.indexOf(a.id);
items.push({ 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.slice(0, 4)) {
requestGroup.items.push({
key: `switch-request-${r.id}`, key: `switch-request-${r.id}`,
label: `Switch Request → ${fallbackRequestName(r)}`, label: fallbackRequestName(r),
onSelect: () => { onSelect: () => {
return routes.navigate('request', { return routes.navigate('request', {
workspaceId: r.workspaceId, workspaceId: r.workspaceId,
@@ -32,25 +90,34 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
}, },
}); });
} }
for (const w of workspaces) {
items.push({ const workspaceGroup: CommandPaletteGroup = {
key: 'workspaces',
label: 'Workspaces',
items: [],
};
for (const w of sortedWorkspaces.slice(0, 4)) {
workspaceGroup.items.push({
key: `switch-workspace-${w.id}`, key: `switch-workspace-${w.id}`,
label: `Switch Workspace → ${w.name}`, label: w.name,
onSelect: async () => { onSelect: () => openWorkspace.mutate({ workspace: w, inNewWindow: false }),
const environmentId = (await getRecentEnvironments(w.id))[0];
return routes.navigate('workspace', {
workspaceId: w.id,
environmentId,
});
},
}); });
} }
return items;
}, [activeEnvironmentId, requests, routes, workspaces]);
const filteredItems = useMemo(() => { return [requestGroup, workspaceGroup];
return items.filter((v) => v.label.toLowerCase().includes(command.toLowerCase())); }, [activeEnvironmentId, openWorkspace, routes, sortedRequests, sortedWorkspaces]);
}, [command, items]);
const filteredGroups = useMemo(
() =>
groups
.map((g) => {
g.items = g.items.filter((v) => v.label.toLowerCase().includes(command.toLowerCase()));
return g;
})
.filter((g) => g.items.length > 0),
[command, groups],
);
const handleSelectAndClose = useCallback( const handleSelectAndClose = useCallback(
(cb: () => void) => { (cb: () => void) => {
@@ -60,44 +127,71 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
[onClose], [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( const handleKeyDown = useCallback(
(e: KeyboardEvent) => { (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown') { const index = allItems.findIndex((v) => v.key === selectedItem?.key);
setSelectedIndex((prev) => prev + 1);
} else if (e.key === 'ArrowUp') { if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) {
setSelectedIndex((prev) => prev - 1); 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') { } else if (e.key === 'Enter') {
const item = filteredItems[selectedIndex]; const selected = allItems[index];
if (item) { setSelectedItemKey(selected?.key ?? null);
handleSelectAndClose(item.onSelect); if (selected) {
handleSelectAndClose(selected.onSelect);
} }
} }
}, },
[filteredItems, handleSelectAndClose, selectedIndex], [allItems, handleSelectAndClose, selectedItem?.key],
); );
return ( return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]"> <div className="h-full max-h-[20rem] w-[400px] grid grid-rows-[auto_minmax(0,1fr)]">
<div className="px-2 py-2 w-full"> <div className="px-2 py-2 w-full">
<Input <PlainInput
hideLabel hideLabel
leftSlot={
<div className="h-md w-10 flex justify-center items-center">
<Icon icon="search" className="text-fg-subtle" />
</div>
}
name="command" name="command"
label="Command" label="Command"
placeholder="Type a command" placeholder="Search or type a command"
className="font-sans !text-base"
defaultValue="" defaultValue=""
onChange={setCommand} onChange={setCommand}
onKeyDown={handleKeyDown} onKeyDownCapture={handleKeyDown}
/> />
</div> </div>
<div className="h-full px-1.5 overflow-y-auto"> <div className="h-full px-1.5 overflow-y-auto pb-1">
{filteredItems.map((v, i) => ( {filteredGroups.map((g) => (
<CommandPaletteItem <div key={g.key} className="mb-1.5">
active={i === selectedIndex} <Heading size={2} className="!text-xs uppercase px-1.5 h-sm flex items-center">
key={v.key} {g.label}
onClick={() => handleSelectAndClose(v.onSelect)} </Heading>
> {g.items.map((v) => (
{v.label} <CommandPaletteItem
</CommandPaletteItem> active={v.key === selectedItem?.key}
key={v.key}
onClick={() => handleSelectAndClose(v.onSelect)}
>
{v.label}
</CommandPaletteItem>
))}
</div>
))} ))}
</div> </div>
</div> </div>
@@ -116,12 +210,15 @@ function CommandPaletteItem({
return ( return (
<button <button
onClick={onClick} onClick={onClick}
tabIndex={active ? undefined : -1}
className={classNames( className={classNames(
'w-full h-xs flex items-center rounded px-1.5 text-fg-subtle', 'w-full h-sm flex items-center rounded px-1.5',
'hover:text-fg',
active && 'bg-background-highlight-secondary text-fg', active && 'bg-background-highlight-secondary text-fg',
!active && 'text-fg-subtle',
)} )}
> >
{children} <span className="truncate">{children}</span>
</button> </button>
); );
} }

View File

@@ -47,13 +47,13 @@ export function Overlay({
variant === 'default' && 'bg-background-backdrop backdrop-blur-sm', variant === 'default' && 'bg-background-backdrop backdrop-blur-sm',
)} )}
/> />
{children}
{/* Show draggable region at the top */} {/* Show draggable region at the top */}
{/* TODO: Figure out tauri drag region and also make clickable still */} {/* TODO: Figure out tauri drag region and also make clickable still */}
{variant === 'default' && ( {variant === 'default' && (
<div data-tauri-drag-region className="absolute top-0 left-0 h-md right-0" /> <div data-tauri-drag-region className="absolute top-0 left-0 h-md right-0" />
)} )}
{children}
</motion.div> </motion.div>
</FocusTrap> </FocusTrap>
)} )}

View File

@@ -18,6 +18,7 @@ export interface DialogProps {
hideX?: boolean; hideX?: boolean;
noPadding?: boolean; noPadding?: boolean;
noScroll?: boolean; noScroll?: boolean;
vAlign?: 'top' | 'center';
} }
export function Dialog({ export function Dialog({
@@ -31,6 +32,7 @@ export function Dialog({
hideX, hideX,
noPadding, noPadding,
noScroll, noScroll,
vAlign = 'center',
}: DialogProps) { }: DialogProps) {
const titleId = useMemo(() => Math.random().toString(36).slice(2), []); const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
const descriptionId = useMemo( const descriptionId = useMemo(
@@ -50,7 +52,13 @@ export function Dialog({
return ( return (
<Overlay open={open} onClose={onClose} portalName="dialog"> <Overlay open={open} onClose={onClose} portalName="dialog">
<div className="x-theme-dialog absolute inset-0 flex items-center justify-center pointer-events-none"> <div
className={classNames(
'x-theme-dialog absolute inset-0 flex flex-col items-center pointer-events-none my-5',
vAlign === 'top' && 'justify-start',
vAlign === 'center' && 'justify-center',
)}
>
<div <div
role="dialog" role="dialog"
aria-labelledby={titleId} aria-labelledby={titleId}
@@ -71,7 +79,7 @@ export function Dialog({
size === 'md' && 'w-[45rem] max-h-[80vh]', size === 'md' && 'w-[45rem] max-h-[80vh]',
size === 'lg' && 'w-[65rem] max-h-[80vh]', size === 'lg' && 'w-[65rem] max-h-[80vh]',
size === 'full' && 'w-[100vw] h-[100vh]', size === 'full' && 'w-[100vw] h-[100vh]',
size === 'dynamic' && 'min-w-[30vw] max-w-[80vw]', size === 'dynamic' && 'min-w-[20rem] max-w-[80vw] max-h-[80vh]',
)} )}
> >
{title ? ( {title ? (

View File

@@ -332,6 +332,20 @@ function getExtensions({
return [ return [
...baseExtensions, // Must be first ...baseExtensions, // Must be first
EditorView.domEventHandlers({
focus: () => {
onFocus.current?.();
},
blur: () => {
onBlur.current?.();
},
keydown: (e) => {
onKeyDown.current?.(e);
},
paste: (e) => {
onPaste.current?.(e.clipboardData?.getData('text/plain') ?? '');
},
}),
tooltips({ parent }), tooltips({ parent }),
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap), keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
...(singleLine ? [singleLineExt()] : []), ...(singleLine ? [singleLineExt()] : []),
@@ -349,21 +363,6 @@ function getExtensions({
onChange.current?.(update.state.doc.toString()); onChange.current?.(update.state.doc.toString());
} }
}), }),
EditorView.domEventHandlers({
focus: () => {
onFocus.current?.();
},
blur: () => {
onBlur.current?.();
},
keydown: (e) => {
onKeyDown.current?.(e);
},
paste: (e) => {
onPaste.current?.(e.clipboardData?.getData('text/plain') ?? '');
},
}),
]; ];
} }

View File

@@ -6,7 +6,7 @@ import type { InputProps } from './Input';
import { HStack } from './Stacks'; import { HStack } from './Stacks';
export type PlainInputProps = Omit<InputProps, 'wrapLines' | 'onKeyDown' | 'type'> & { export type PlainInputProps = Omit<InputProps, 'wrapLines' | 'onKeyDown' | 'type'> & {
type: 'text' | 'password' | 'number'; type?: 'text' | 'password' | 'number';
step?: number; step?: number;
}; };
@@ -54,7 +54,7 @@ export const PlainInput = forwardRef<HTMLInputElement, PlainInputProps>(function
const inputClassName = classNames( const inputClassName = classNames(
className, className,
'!bg-transparent min-w-0 h-auto w-full focus:outline-none placeholder:text-placeholder', '!bg-transparent min-w-0 h-auto w-full focus:outline-none placeholder:text-placeholder',
'px-1.5 text-xs font-mono', 'px-1.5 text-xs font-mono cursor-text',
); );
const isValid = useMemo(() => { const isValid = useMemo(() => {

View File

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

View File

@@ -4,10 +4,8 @@ import type { Workspace } from '../lib/models';
import { useAppRoutes } from './useAppRoutes'; import { useAppRoutes } from './useAppRoutes';
import { getRecentEnvironments } from './useRecentEnvironments'; import { getRecentEnvironments } from './useRecentEnvironments';
import { getRecentRequests } from './useRecentRequests'; import { getRecentRequests } from './useRecentRequests';
import { useSettings } from './useSettings';
export function useOpenWorkspace() { export function useOpenWorkspace() {
const settings = useSettings();
const routes = useAppRoutes(); const routes = useAppRoutes();
return useMutation({ return useMutation({
@@ -18,7 +16,7 @@ export function useOpenWorkspace() {
workspace: Workspace; workspace: Workspace;
inNewWindow: boolean; inNewWindow: boolean;
}) => { }) => {
if (settings == null || workspace == null) return; if (workspace == null) return;
if (inNewWindow) { if (inNewWindow) {
const environmentId = (await getRecentEnvironments(workspace.id))[0]; const environmentId = (await getRecentEnvironments(workspace.id))[0];