'use client'; /** * Global command palette (Cmd+K / Ctrl+K). * Dynamic: fetches real projects, repos, rooms from API. * Actions: navigate to project/repo/room, create project, create repo. */ import { useState, useEffect, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { CommandDialog, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandShortcut, } from '@/components/ui/command'; import { FolderKanban, GitBranch, Hash, Plus, Bell, Search, } from 'lucide-react'; import { getRegisteredCommands } from '@/hooks/useCommandRegistry'; import type { CommandItem as RegistryCommandItem } from '@/hooks/useCommandRegistry'; import { formatShortcut } from '@/hooks/useKeyboardShortcut'; import { getCurrentUserProjects, projectRepos, roomList } from '@/client'; import type { UserProjectInfo, ProjectRepositoryItem, RoomResponse } from '@/client'; // ── Icons ──────────────────────────────────────────────────────────────────── const iconMap: Record> = { FolderKanban, GitBranch, Hash, Plus, Bell, Search, }; // ── Command item type ──────────────────────────────────────────────────────── interface PaletteItem { id: string; label: string; icon: string; shortcut?: { key: string; meta?: boolean; shift?: boolean }; action: () => void; group: string; keywords: string[]; } // ── Static quick actions ───────────────────────────────────────────────────── function buildStaticActions(navigate: ReturnType): PaletteItem[] { return [ { id: 'goto-notifications', label: 'Go to Notifications', icon: 'Bell', shortcut: { key: 'n', meta: true }, action: () => navigate('/notify'), group: 'Navigation', keywords: ['goto', 'notifications', 'inbox'], }, { id: 'create-project', label: 'Create Project', icon: 'Plus', shortcut: { key: 'c', meta: true, shift: true }, action: () => navigate('/init/project'), group: 'Create', keywords: ['create', 'new', 'project'], }, ]; } // ── Main palette ───────────────────────────────────────────────────────────── export function CommandPalette() { const [open, setOpen] = useState(false); const navigate = useNavigate(); // Fetch projects for the current user (no workspace dependency) const { data: projectsData } = useQuery({ queryKey: ['commandPaletteProjects'], queryFn: async () => { const resp = await getCurrentUserProjects(); return resp.data?.data ?? { projects: [] as UserProjectInfo[], total_count: 0 }; }, }); const projects = projectsData?.projects ?? []; // Derived project names for repo/room queries const projectNames = useMemo( () => projects.map(p => p.name), [projects], ); // Fetch repos per project (up to 5 projects to keep it fast) const { data: reposData } = useQuery({ queryKey: ['commandPaletteRepos', projectNames], queryFn: async () => { const results: Record = {}; for (const name of projectNames.slice(0, 5)) { try { const resp = await projectRepos({ path: { project_name: name } }); const data = resp.data?.data; if (data?.items) results[name] = data.items; } catch { /* skip */ } } return results; }, enabled: projectNames.length > 0, }); // Fetch rooms for each project (up to 5 to keep it fast) const { data: roomsData } = useQuery({ queryKey: ['commandPaletteRooms', projectNames], queryFn: async () => { const results: Record = {}; for (const name of projectNames.slice(0, 5)) { try { const resp = await roomList({ path: { project_name: name } }); const data = resp.data?.data; if (data) results[name] = (data as any)?.rooms ?? (Array.isArray(data) ? data : []); } catch { /* skip */ } } return results; }, enabled: projectNames.length > 0, }); // Global shortcut: Ctrl+Alt+F (Windows/Linux) or Cmd+Ctrl+F (Mac) useEffect(() => { const handler = (e: KeyboardEvent) => { const isMac = navigator.platform?.toUpperCase().includes('MAC') || navigator.userAgent?.includes('Mac'); const match = isMac ? e.metaKey && e.ctrlKey && e.key === 'f' : e.ctrlKey && e.altKey && e.key === 'f'; if (match) { e.preventDefault(); setOpen((v) => !v); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, []); // Close on navigation useEffect(() => { const handler = () => setOpen(false); window.addEventListener('popstate', handler); return () => window.removeEventListener('popstate', handler); }, []); const handleSelect = useCallback((action: () => void) => { setOpen(false); action(); }, []); // Build search text including keywords for cmdk filtering const searchValue = useCallback((item: PaletteItem | RegistryCommandItem) => { return [item.label, ...(item.keywords ?? [])].join(' '); }, []); // ── Build dynamic command list ────────────────────────────────────────── const allRooms = roomsData ?? {}; const allRepos = reposData ?? {}; const projectItems: PaletteItem[] = projects.map(p => ({ id: `project-${p.name}`, label: p.display_name || p.name, icon: 'FolderKanban', action: () => navigate(`/project/${p.name}`), group: 'Projects', keywords: ['project', p.name, p.display_name, ...(p.description ? [p.description] : [])], })); const repoItems: PaletteItem[] = []; for (const [projName, repos] of Object.entries(allRepos)) { for (const r of repos) { repoItems.push({ id: `repo-${projName}-${r.repo_name}`, label: `${r.repo_name} (${projName})`, icon: 'GitBranch', action: () => navigate(`/repository/${projName}/${r.repo_name}`), group: 'Repositories', keywords: ['repo', 'repository', r.repo_name, projName, ...(r.description ? [r.description] : [])], }); } } const roomItems: PaletteItem[] = []; for (const [projName, rooms] of Object.entries(allRooms)) { for (const r of rooms) { roomItems.push({ id: `room-${r.id}`, label: `${r.room_name} (${projName})`, icon: 'Hash', action: () => navigate(`/project/${projName}/room/${r.id}`), group: 'Rooms', keywords: ['room', 'chat', 'channel', r.room_name, projName], }); } } // Per-project "create repo" actions const createRepoItems: PaletteItem[] = projects.slice(0, 10).map(p => ({ id: `create-repo-${p.name}`, label: `Create Repo in ${p.display_name || p.name}`, icon: 'Plus', action: () => navigate(`/project/${p.name}/repositories`), group: 'Create', keywords: ['create', 'new', 'repo', 'repository', p.name, p.display_name], })); const staticActions = buildStaticActions(navigate); const registered = getRegisteredCommands(); const allCommands = [ ...staticActions, ...projectItems, ...repoItems, ...roomItems, ...createRepoItems, ...registered, ] as (PaletteItem | RegistryCommandItem)[]; // Group commands by their `group` field const grouped = useMemo(() => { const groups = new Map(); for (const cmd of allCommands) { const g = groups.get(cmd.group) ?? []; g.push(cmd); groups.set(cmd.group, g); } return groups; }, [allCommands]); return ( No results found. {Array.from(grouped.entries()).map(([group, cmds]) => ( {cmds.map((cmd) => { const Icon = iconMap[(cmd as PaletteItem).icon ?? ''] ?? Search; const shortcut = cmd.shortcut; return ( handleSelect(cmd.action)} > {cmd.label} {shortcut && ( {formatShortcut(shortcut)} )} ); })} ))} ); }