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 type { ReactNode } from 'react';
import type { KeyboardEvent, ReactNode } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
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 { useWorkspaces } from '../hooks/useWorkspaces';
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 }) {
const [selectedIndex, setSelectedIndex] = useState<number>(0);
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
const routes = useAppRoutes();
const activeEnvironmentId = useActiveEnvironmentId();
const workspaces = useWorkspaces();
const recentWorkspaces = useRecentWorkspaces();
const requests = useRequests();
const recentRequests = useRecentRequests();
const [command, setCommand] = useState<string>('');
const openWorkspace = useOpenWorkspace();
const items = useMemo<{ label: string; onSelect: () => void; key: string }[]>(() => {
const items = [];
for (const r of requests) {
items.push({
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.slice(0, 4)) {
requestGroup.items.push({
key: `switch-request-${r.id}`,
label: `Switch Request → ${fallbackRequestName(r)}`,
label: fallbackRequestName(r),
onSelect: () => {
return routes.navigate('request', {
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}`,
label: `Switch Workspace → ${w.name}`,
onSelect: async () => {
const environmentId = (await getRecentEnvironments(w.id))[0];
return routes.navigate('workspace', {
workspaceId: w.id,
environmentId,
});
},
label: w.name,
onSelect: () => openWorkspace.mutate({ workspace: w, inNewWindow: false }),
});
}
return items;
}, [activeEnvironmentId, requests, routes, workspaces]);
const filteredItems = useMemo(() => {
return items.filter((v) => v.label.toLowerCase().includes(command.toLowerCase()));
}, [command, items]);
return [requestGroup, workspaceGroup];
}, [activeEnvironmentId, openWorkspace, routes, sortedRequests, sortedWorkspaces]);
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(
(cb: () => void) => {
@@ -60,44 +127,71 @@ 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;
if (selectedItem == null) {
selectedItem = allItems[0] ?? null;
}
return { selectedItem, allItems };
}, [filteredGroups, selectedItemKey]);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
setSelectedIndex((prev) => prev + 1);
} else if (e.key === 'ArrowUp') {
setSelectedIndex((prev) => prev - 1);
(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 item = filteredItems[selectedIndex];
if (item) {
handleSelectAndClose(item.onSelect);
const selected = allItems[index];
setSelectedItemKey(selected?.key ?? null);
if (selected) {
handleSelectAndClose(selected.onSelect);
}
}
},
[filteredItems, handleSelectAndClose, selectedIndex],
[allItems, handleSelectAndClose, selectedItem?.key],
);
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">
<Input
<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="Type a command"
placeholder="Search or type a command"
className="font-sans !text-base"
defaultValue=""
onChange={setCommand}
onKeyDown={handleKeyDown}
onKeyDownCapture={handleKeyDown}
/>
</div>
<div className="h-full px-1.5 overflow-y-auto">
{filteredItems.map((v, i) => (
<CommandPaletteItem
active={i === selectedIndex}
key={v.key}
onClick={() => handleSelectAndClose(v.onSelect)}
>
{v.label}
</CommandPaletteItem>
<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.map((v) => (
<CommandPaletteItem
active={v.key === selectedItem?.key}
key={v.key}
onClick={() => handleSelectAndClose(v.onSelect)}
>
{v.label}
</CommandPaletteItem>
))}
</div>
))}
</div>
</div>
@@ -116,12 +210,15 @@ function CommandPaletteItem({
return (
<button
onClick={onClick}
tabIndex={active ? undefined : -1}
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 && 'text-fg-subtle',
)}
>
{children}
<span className="truncate">{children}</span>
</button>
);
}

View File

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

View File

@@ -18,6 +18,7 @@ export interface DialogProps {
hideX?: boolean;
noPadding?: boolean;
noScroll?: boolean;
vAlign?: 'top' | 'center';
}
export function Dialog({
@@ -31,6 +32,7 @@ export function Dialog({
hideX,
noPadding,
noScroll,
vAlign = 'center',
}: DialogProps) {
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
const descriptionId = useMemo(
@@ -50,7 +52,13 @@ export function Dialog({
return (
<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
role="dialog"
aria-labelledby={titleId}
@@ -71,7 +79,7 @@ export function Dialog({
size === 'md' && 'w-[45rem] max-h-[80vh]',
size === 'lg' && 'w-[65rem] max-h-[80vh]',
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 ? (

View File

@@ -332,6 +332,20 @@ function getExtensions({
return [
...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 }),
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
...(singleLine ? [singleLineExt()] : []),
@@ -349,21 +363,6 @@ function getExtensions({
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';
export type PlainInputProps = Omit<InputProps, 'wrapLines' | 'onKeyDown' | 'type'> & {
type: 'text' | 'password' | 'number';
type?: 'text' | 'password' | 'number';
step?: number;
};
@@ -54,7 +54,7 @@ export const PlainInput = forwardRef<HTMLInputElement, PlainInputProps>(function
const inputClassName = classNames(
className,
'!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(() => {

View File

@@ -1,21 +1,15 @@
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?.isDev) {
return;
}
dialog.toggle({
id: 'command_palette',
size: 'md',
size: 'dynamic',
hideX: true,
vAlign: 'top',
noPadding: true,
noScroll: true,
render: ({ hide }) => <CommandPalette onClose={hide} />,

View File

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