Skip to content
209 linesCodeBlameRaw
1import { Command as Cmdk } from "cmdk";
2import { Code2, CornerDownLeft, Search } from "lucide-react";
3import { Dialog as Primitive } from "radix-ui";
4import { useEffect, useState } from "react";
5import { useFetcher, useNavigate } from "react-router";
6
7import type { SiteHit } from "@g1t/contracts";
8
9import { searchHref } from "../lib/search";
10import { hitIcon } from "./search";
11import type { PaletteCommand } from "./command-palette";
12import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from "./ui/combobox";
13import { SkeletonRows } from "./ui/skeleton";
14
15/** How long typing has to pause before results are asked for. */
16const PAUSE_MS = 140;
17
18function hitHint(hit: SiteHit): string {
19 switch (hit.kind) {
20 case "repository":
21 return "Repository";
22 case "issue":
23 return `${hit.repo} #${hit.number}`;
24 case "pull":
25 return `${hit.repo} #${hit.number}`;
26 case "user":
27 return `Person · ${hit.slug}`;
28 case "workspace":
29 return `Workspace · ${hit.slug}`;
30 default:
31 return hit.repo ?? "";
32 }
33}
34
35/**
36 * ⌘K: pages and actions from what the page already knows, and, as someone
37 * types, repositories, issues, pull requests and people from search,
38 * public ones for anyone and private ones for their workspaces' members.
39 * The first row always searches all of g1t for what was typed.
40 */
41export default function CommandPaletteDialog({
42 open,
43 onOpenChange,
44 commands,
45 repo,
46 onCloseAutoFocus,
47}: {
48 open: boolean;
49 onOpenChange: (open: boolean) => void;
50 commands: PaletteCommand[];
51 /** The repository being looked at, `owner/name`, to offer searching its code. */
52 repo?: string | null;
53 /** Where focus goes once it closes (command-palette.tsx). */
54 onCloseAutoFocus?: (event: Event) => void;
55}) {
56 const navigate = useNavigate();
57 const fetcher = useFetcher<{ q: string; hits: SiteHit[] }>();
58 const [query, setQuery] = useState("");
59 const trimmed = query.trim();
60
61 useEffect(() => {
62 if (open) setQuery("");
63 }, [open]);
64 useEffect(() => {
65 if (trimmed.length < 2) return;
66 const timer = setTimeout(() => fetcher.load(`/search.json?q=${encodeURIComponent(trimmed)}`), PAUSE_MS);
67 return () => clearTimeout(timer);
68 // `fetcher` changes identity as it loads; only the text should restart it.
69 // eslint-disable-next-line react-hooks/exhaustive-deps
70 }, [trimmed]);
71
72 const hits = trimmed.length >= 2 && fetcher.data?.q === trimmed ? fetcher.data.hits : [];
73 const go = (to: string) => {
74 onOpenChange(false);
75 // The docs and other sites are full page loads.
76 if (/^https?:\/\//.test(to)) window.location.assign(to);
77 else navigate(to);
78 };
79 const words = trimmed.toLowerCase().split(/\s+/).filter(Boolean);
80 const matching = commands.filter((command) => {
81 const text = `${command.label} ${command.hint ?? ""}`.toLowerCase();
82 return words.every((word) => text.includes(word));
83 });
84 const pages = matching.filter((command) => command.to != null).slice(0, trimmed ? 8 : 12);
85 // Things done in place, such as switching the theme, once something is typed.
86 const actions = trimmed ? matching.filter((command) => command.run != null).slice(0, 6) : [];
87 const itemClass = "gap-3 rounded-lg px-3 py-2";
88
89 return (
90 <Primitive.Root open={open} onOpenChange={onOpenChange}>
91 <Primitive.Portal>
92 <Primitive.Overlay className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out motion-reduce:animate-none" />
93 <Primitive.Content
94 aria-describedby={undefined}
95 onCloseAutoFocus={onCloseAutoFocus}
96 className="fixed top-[12vh] left-1/2 z-50 w-[calc(100vw-2rem)] max-sm:top-[calc(env(safe-area-inset-top)+0.5rem)] max-sm:w-[calc(100vw-1rem)] max-w-xl -translate-x-1/2 overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60 outline-none"
97 >
98 <Primitive.Title className="sr-only">Go to or search</Primitive.Title>
99 {/* Filtering is done here: search already filtered its results. */}
100 <Command shouldFilter={false} loop>
101 <div className="flex items-center gap-3 border-b border-line px-4">
102 <Search size={16} className="shrink-0 text-faint" />
103 <Cmdk.Input
104 value={query}
105 onValueChange={setQuery}
106 placeholder="Search g1t, or go to a page…"
107 autoComplete="off"
108 data-1p-ignore
109 className="h-12 grow border-0 bg-transparent text-sm shadow-none outline-none placeholder:text-faint focus-visible:outline-none"
110 />
111 <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd>
112 </div>
113 <CommandList className="max-h-[55vh] p-1.5 max-sm:max-h-[calc(var(--vv-height,100dvh)-env(safe-area-inset-top)-5.5rem)]">
114 <CommandEmpty>Nothing matches.</CommandEmpty>
115 {trimmed && (
116 <CommandGroup heading="Search">
117 <CommandItem value="search:all" onSelect={() => go(searchHref(trimmed))} className={itemClass}>
118 <Search />
119 <span className="min-w-0 grow truncate">
120 Search g1t for <span className="font-medium text-fg">“{trimmed}”</span>
121 </span>
122 <CornerDownLeft className="size-3.5! text-faint!" />
123 </CommandItem>
124 <CommandItem value="search:code" onSelect={() => go(searchHref(trimmed, "code"))} className={itemClass}>
125 <Code2 />
126 <span className="min-w-0 grow truncate">
127 Search code for <span className="font-medium text-fg">“{trimmed}”</span>
128 </span>
129 </CommandItem>
130 {repo && (
131 <CommandItem
132 value="search:repo"
133 onSelect={() => go(searchHref(`${trimmed} repo:${repo}`, "code"))}
134 className={itemClass}
135 >
136 <Code2 />
137 <span className="min-w-0 grow truncate">
138 Search <span className="font-mono">{repo}</span> for{" "}
139 <span className="font-medium text-fg">“{trimmed}”</span>
140 </span>
141 </CommandItem>
142 )}
143 </CommandGroup>
144 )}
145 {hits.length > 0 && (
146 <CommandGroup heading="Results">
147 {hits.map((hit) => (
148 <CommandItem
149 key={`${hit.kind}:${hit.url}`}
150 value={`hit:${hit.kind}:${hit.url}`}
151 onSelect={() => go(hit.url)}
152 className={itemClass}
153 >
154 {hitIcon(hit)}
155 <span className="min-w-0 grow truncate">
156 {hit.kind === "repository" ? <span className="font-mono">{hit.title}</span> : hit.title}
157 </span>
158 <span className="shrink-0 truncate text-xs text-faint">{hitHint(hit)}</span>
159 </CommandItem>
160 ))}
161 </CommandGroup>
162 )}
163 {pages.length > 0 && (
164 <CommandGroup heading="Go to">
165 {pages.map((command) => (
166 <CommandItem
167 key={`${command.to}-${command.label}`}
168 value={`page:${command.to}:${command.label}`}
169 onSelect={() => go(command.to!)}
170 className={itemClass}
171 >
172 <span className="shrink-0 text-faint">{command.icon}</span>
173 <span className="min-w-0 grow truncate">{command.label}</span>
174 {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>}
175 </CommandItem>
176 ))}
177 </CommandGroup>
178 )}
179 {actions.length > 0 && (
180 <CommandGroup heading="Commands">
181 {actions.map((command) => (
182 <CommandItem
183 key={`run-${command.label}`}
184 value={`run:${command.label}`}
185 onSelect={() => {
186 onOpenChange(false);
187 command.run!();
188 }}
189 className={itemClass}
190 >
191 <span className="shrink-0 text-faint">{command.icon}</span>
192 <span className="min-w-0 grow truncate">{command.label}</span>
193 {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>}
194 </CommandItem>
195 ))}
196 </CommandGroup>
197 )}
198 </CommandList>
199 </Command>
200 {hits.length === 0 && fetcher.state === "loading" && trimmed.length >= 2 && (
201 <div aria-busy="true" className="border-t border-line">
202 <SkeletonRows rows={3} rowClassName="h-9 px-4" />
203 </div>
204 )}
205 </Primitive.Content>
206 </Primitive.Portal>
207 </Primitive.Root>
208 );
209}