mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-14 06:16:08 +01:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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') ?? '');
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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} />,
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user