Repository Public
1'use client';2import { useEffect, useState } from 'react';3import { useRouter } from 'next/navigation';4import { Command } from 'cmdk';5import { useQuery } from '@tanstack/react-query';6import type { SearchHit } from '@cr/shared';7import { File, FolderGit2, LayoutPanelLeft, Plus, Search } from 'lucide-react';8import { api, asList, qs } from '@/lib/api';9import { useProjects, useSessions } from '@/lib/queries';10import { PRESETS, useUi } from '@/stores/ui';11import { useCurrentProject, sortSessions } from '@/hooks/use-current';12import { useOpenSession } from '@/hooks/use-nav';13import { SessionDot } from '@/components/sessions/state-badge';14import { NAV } from './nav';1516const item = 'flex min-h-9 cursor-default select-none items-center gap-2.5 rounded-sm px-2.5 text-[13px] text-text-2 data-[selected=true]:bg-hover data-[selected=true]:text-text pointer-coarse:min-h-11';17const group = '[&_[cmdk-group-heading]]:px-2.5 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-[11px] [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wide [&_[cmdk-group-heading]]:text-muted';1819export function CommandPalette() {20 const open = useUi((s) => s.paletteOpen);21 const setUi = useUi((s) => s.set);22 const router = useRouter();23 const openSession = useOpenSession();24 const sessions = useSessions().data ?? [];25 const projects = useProjects().data ?? [];26 const project = useCurrentProject();27 const [query, setQuery] = useState('');28 const [debounced, setDebounced] = useState('');29 useEffect(() => {30 const t = setTimeout(() => setDebounced(query.trim()), 200);31 return () => clearTimeout(t);32 }, [query]);33 useEffect(() => {34 if (!open) setQuery('');35 }, [open]);36 const files = useQuery({37 queryKey: ['palette-files', project?.id, debounced],38 queryFn: async () => asList<SearchHit>(await api.get(`/projects/${project?.id}/search${qs({ q: debounced, mode: 'name' })}`)),39 enabled: open && !!project && debounced.length >= 2,40 staleTime: 10_000,41 });42 const run = (fn: () => void) => {43 setUi({ paletteOpen: false });44 fn();45 };4647 return (48 <Command.Dialog49 open={open}50 onOpenChange={(o) => setUi({ paletteOpen: o })}51 label="Command palette"52 overlayClassName="anim-fade fixed inset-0 z-[60] bg-scrim"53 contentClassName="anim-pop fixed left-1/2 top-[12dvh] z-[60] w-[min(600px,calc(100vw-24px))] -translate-x-1/2 overflow-hidden rounded-lg border border-line-2 bg-surface shadow-pop"54 >55 <div className="flex items-center gap-2.5 border-b border-line px-3.5">56 <Search className="size-4 shrink-0 text-muted" aria-hidden />57 <Command.Input value={query} onValueChange={setQuery} placeholder="Search sessions, projects, files, commands…" data-testid="palette-input" className="h-12 w-full bg-transparent text-sm text-text placeholder:text-muted focus:outline-none" />58 </div>59 <Command.List className="max-h-[min(60dvh,440px)] overflow-y-auto p-1.5">60 <Command.Empty className="px-3 py-8 text-center text-xs text-muted">No results</Command.Empty>61 <Command.Group heading="Actions" className={group}>62 <Command.Item className={item} onSelect={() => run(() => useUi.getState().openNewSession())}>63 <Plus className="size-4 text-muted" /> New session64 </Command.Item>65 </Command.Group>66 {sessions.length > 0 && (67 <Command.Group heading="Switch session" className={group}>68 {sortSessions(sessions).map((s) => (69 <Command.Item key={s.id} value={`session ${s.title} ${s.projectName ?? ''} ${s.id}`} className={item} onSelect={() => run(() => openSession(s.id))}>70 <SessionDot state={s.state} /> <span className="truncate">{s.title}</span>71 <span className="ml-auto shrink-0 text-[11px] text-muted">{s.projectName ?? 'No project'}</span>72 </Command.Item>73 ))}74 </Command.Group>75 )}76 {projects.length > 0 && (77 <Command.Group heading="Open project" className={group}>78 {projects.map((p) => (79 <Command.Item key={p.id} value={`project ${p.name} ${p.path}`} className={item} onSelect={() => run(() => router.push(`/files/?p=${p.id}`))}>80 <FolderGit2 className="size-4 text-muted" /> <span className="truncate">{p.name}</span>81 <span className="ml-auto shrink-0 font-mono text-[11px] text-muted">{p.rootLabel}/{p.path}</span>82 </Command.Item>83 ))}84 </Command.Group>85 )}86 {project && (files.data?.length ?? 0) > 0 && (87 <Command.Group heading={`Files in ${project.name}`} className={group}>88 {files.data?.slice(0, 20).map((f) => (89 <Command.Item key={f.path} value={`file ${f.path}`} className={item} onSelect={() => run(() => router.push(`/files/?p=${project.id}&path=${encodeURIComponent(f.path)}`))}>90 <File className="size-4 text-muted" /> <span className="truncate font-mono text-xs">{f.path}</span>91 </Command.Item>92 ))}93 </Command.Group>94 )}95 <Command.Group heading="Go to" className={group}>96 {NAV.map((n) => (97 <Command.Item key={n.href} value={`go to ${n.label}`} className={item} onSelect={() => run(() => router.push(n.href))}>98 <n.icon className="size-4 text-muted" /> {n.label}99 </Command.Item>100 ))}101 </Command.Group>102 <Command.Group heading="Layout" className={group}>103 {PRESETS.map((p) => (104 <Command.Item key={p.id} value={`layout ${p.label}`} className={item} onSelect={() => run(() => { useUi.getState().applyPreset(p.id); router.push('/terminal/'); })}>105 <LayoutPanelLeft className="size-4 text-muted" /> {p.label}106 <span className="ml-auto text-[11px] text-muted">{p.hint}</span>107 </Command.Item>108 ))}109 </Command.Group>110 </Command.List>111 </Command.Dialog>112 );113}114