mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-19 15:21:23 +02: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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user