mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-02-22 15:47:54 +01:00
256 lines
7.8 KiB
TypeScript
256 lines
7.8 KiB
TypeScript
import classNames from 'classnames';
|
|
import type { KeyboardEvent, ReactNode } from 'react';
|
|
import { useCallback, useMemo, useState } from 'react';
|
|
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
|
|
import { useActiveRequestId } from '../hooks/useActiveRequestId';
|
|
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
|
|
import { useAppRoutes } from '../hooks/useAppRoutes';
|
|
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 { Heading } from './core/Heading';
|
|
import { HttpMethodTag } from './core/HttpMethodTag';
|
|
import { Icon } from './core/Icon';
|
|
import { PlainInput } from './core/PlainInput';
|
|
import { HStack } from './core/Stacks';
|
|
|
|
interface CommandPaletteGroup {
|
|
key: string;
|
|
label: ReactNode;
|
|
items: CommandPaletteItem[];
|
|
}
|
|
|
|
type CommandPaletteItem = {
|
|
key: string;
|
|
onSelect: () => void;
|
|
} & ({ searchText: string; label: ReactNode } | { label: string });
|
|
|
|
const MAX_PER_GROUP = 4;
|
|
|
|
export function CommandPalette({ onClose }: { onClose: () => void }) {
|
|
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
|
|
const routes = useAppRoutes();
|
|
const activeEnvironmentId = useActiveEnvironmentId();
|
|
const activeRequestId = useActiveRequestId();
|
|
const activeWorkspaceId = useActiveWorkspaceId();
|
|
const workspaces = useWorkspaces();
|
|
const recentWorkspaces = useRecentWorkspaces();
|
|
const requests = useRequests();
|
|
const recentRequests = useRecentRequests();
|
|
const [command, setCommand] = useState<string>('');
|
|
const openWorkspace = useOpenWorkspace();
|
|
|
|
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) {
|
|
if (r.id === activeRequestId) {
|
|
continue;
|
|
}
|
|
|
|
requestGroup.items.push({
|
|
key: `switch-request-${r.id}`,
|
|
searchText: `${r.method} ${r.name}`,
|
|
label: (
|
|
<HStack space={2}>
|
|
<HttpMethodTag className="text-fg-subtler" shortNames request={r} />
|
|
<div className="truncate">{fallbackRequestName(r)}</div>
|
|
</HStack>
|
|
),
|
|
onSelect: () => {
|
|
return routes.navigate('request', {
|
|
workspaceId: r.workspaceId,
|
|
requestId: r.id,
|
|
environmentId: activeEnvironmentId ?? undefined,
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
const workspaceGroup: CommandPaletteGroup = {
|
|
key: 'workspaces',
|
|
label: 'Workspaces',
|
|
items: [],
|
|
};
|
|
|
|
for (const w of sortedWorkspaces) {
|
|
if (w.id === activeWorkspaceId) {
|
|
continue;
|
|
}
|
|
workspaceGroup.items.push({
|
|
key: `switch-workspace-${w.id}`,
|
|
label: w.name,
|
|
onSelect: () => openWorkspace.mutate({ workspace: w, inNewWindow: false }),
|
|
});
|
|
}
|
|
|
|
return [requestGroup, workspaceGroup];
|
|
}, [
|
|
activeEnvironmentId,
|
|
activeRequestId,
|
|
activeWorkspaceId,
|
|
openWorkspace,
|
|
routes,
|
|
sortedRequests,
|
|
sortedWorkspaces,
|
|
]);
|
|
|
|
const filteredGroups = useMemo(
|
|
() =>
|
|
groups
|
|
.map((g) => {
|
|
g.items = g.items.filter((v) => {
|
|
const s = 'searchText' in v ? v.searchText : v.label;
|
|
return s.toLowerCase().includes(command.toLowerCase());
|
|
});
|
|
return g;
|
|
})
|
|
.filter((g) => g.items.length > 0),
|
|
[command, groups],
|
|
);
|
|
|
|
const handleSelectAndClose = useCallback(
|
|
(cb: () => void) => {
|
|
onClose();
|
|
cb();
|
|
},
|
|
[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<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 selected = allItems[index];
|
|
setSelectedItemKey(selected?.key ?? null);
|
|
if (selected) {
|
|
handleSelectAndClose(selected.onSelect);
|
|
}
|
|
}
|
|
},
|
|
[allItems, handleSelectAndClose, selectedItem?.key],
|
|
);
|
|
|
|
return (
|
|
<div className="h-full max-h-[20rem] w-[400px] grid grid-rows-[auto_minmax(0,1fr)]">
|
|
<div className="px-2 py-2 w-full">
|
|
<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="Search or type a command"
|
|
className="font-sans !text-base"
|
|
defaultValue={command}
|
|
onChange={setCommand}
|
|
onKeyDownCapture={handleKeyDown}
|
|
/>
|
|
</div>
|
|
<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.slice(0, MAX_PER_GROUP).map((v) => (
|
|
<CommandPaletteItem
|
|
active={v.key === selectedItem?.key}
|
|
key={v.key}
|
|
onClick={() => handleSelectAndClose(v.onSelect)}
|
|
>
|
|
{v.label}
|
|
</CommandPaletteItem>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CommandPaletteItem({
|
|
children,
|
|
active,
|
|
onClick,
|
|
}: {
|
|
children: ReactNode;
|
|
active: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
tabIndex={active ? undefined : -1}
|
|
className={classNames(
|
|
'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',
|
|
)}
|
|
>
|
|
<span className="truncate">{children}</span>
|
|
</button>
|
|
);
|
|
}
|