flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/web/app/components/command-palette.tsx

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