From b0e4ece2782e6b7aa7068e0ccc8a5d1a5aaa2358 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Fri, 7 Jun 2024 21:59:57 -0700 Subject: [PATCH] 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. --- src-web/components/CommandPalette.tsx | 193 ++++++++++++++++------ src-web/components/Overlay.tsx | 2 +- src-web/components/core/Dialog.tsx | 12 +- src-web/components/core/Editor/Editor.tsx | 29 ++-- src-web/components/core/PlainInput.tsx | 4 +- src-web/hooks/useCommandPalette.tsx | 10 +- src-web/hooks/useOpenWorkspace.ts | 4 +- 7 files changed, 175 insertions(+), 79 deletions(-) diff --git a/src-web/components/CommandPalette.tsx b/src-web/components/CommandPalette.tsx index 92c2f5c6..e99f5320 100644 --- a/src-web/components/CommandPalette.tsx +++ b/src-web/components/CommandPalette.tsx @@ -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(0); + const [selectedItemKey, setSelectedItemKey] = useState(null); const routes = useAppRoutes(); const activeEnvironmentId = useActiveEnvironmentId(); const workspaces = useWorkspaces(); + const recentWorkspaces = useRecentWorkspaces(); const requests = useRequests(); + const recentRequests = useRecentRequests(); const [command, setCommand] = useState(''); + 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(() => { + 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) => { + 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 ( -
+
- + +
+ } 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} />
-
- {filteredItems.map((v, i) => ( - handleSelectAndClose(v.onSelect)} - > - {v.label} - +
+ {filteredGroups.map((g) => ( +
+ + {g.label} + + {g.items.map((v) => ( + handleSelectAndClose(v.onSelect)} + > + {v.label} + + ))} +
))}
@@ -116,12 +210,15 @@ function CommandPaletteItem({ return ( ); } diff --git a/src-web/components/Overlay.tsx b/src-web/components/Overlay.tsx index 052b7b4e..e0217a73 100644 --- a/src-web/components/Overlay.tsx +++ b/src-web/components/Overlay.tsx @@ -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' && (
)} + {children} )} diff --git a/src-web/components/core/Dialog.tsx b/src-web/components/core/Dialog.tsx index 5364aac6..c809e744 100644 --- a/src-web/components/core/Dialog.tsx +++ b/src-web/components/core/Dialog.tsx @@ -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 ( -
+
{title ? ( diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx index 41460272..f1adce95 100644 --- a/src-web/components/core/Editor/Editor.tsx +++ b/src-web/components/core/Editor/Editor.tsx @@ -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') ?? ''); - }, - }), ]; } diff --git a/src-web/components/core/PlainInput.tsx b/src-web/components/core/PlainInput.tsx index 9995f378..57355eb0 100644 --- a/src-web/components/core/PlainInput.tsx +++ b/src-web/components/core/PlainInput.tsx @@ -6,7 +6,7 @@ import type { InputProps } from './Input'; import { HStack } from './Stacks'; export type PlainInputProps = Omit & { - type: 'text' | 'password' | 'number'; + type?: 'text' | 'password' | 'number'; step?: number; }; @@ -54,7 +54,7 @@ export const PlainInput = forwardRef(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(() => { diff --git a/src-web/hooks/useCommandPalette.tsx b/src-web/hooks/useCommandPalette.tsx index 279cec94..45d8dd49 100644 --- a/src-web/hooks/useCommandPalette.tsx +++ b/src-web/hooks/useCommandPalette.tsx @@ -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 }) => , diff --git a/src-web/hooks/useOpenWorkspace.ts b/src-web/hooks/useOpenWorkspace.ts index e0f8c8b9..aea48100 100644 --- a/src-web/hooks/useOpenWorkspace.ts +++ b/src-web/hooks/useOpenWorkspace.ts @@ -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];