Skip to content
File

Blob: src/client/islands/ref-picker.tsx

typescript290 lines
1/// <reference lib="dom" />
2 
3import { useEffect, useMemo, useRef, useState } from "react";
4import { Check, GitBranch, Tag } from "lucide-react";
5 
6import { hydrateIsland } from "@/client/hydrate";
7import { buttonClasses } from "@/client/components/ui/button";
8 
9type RefItem = {
10 name: string;
11 displayName: string;
12};
13 
14type RefApiResponse = {
15 branches?: RefItem[];
16 tags?: RefItem[];
17};
18 
19export type RefPickerProps = {
20 owner: string;
21 repo: string;
22 currentRef: string;
23};
24 
25function decodeSafe(value: string): string {
26 try {
27 return decodeURIComponent(value);
28 } catch {
29 return value;
30 }
31}
32 
33function formatRefLabel(ref: string): string {
34 return /^[0-9a-f]{40}$/i.test(ref) ? `${ref.slice(0, 7)}...` : ref || "...";
35}
36 
37function buildRefHref(currentUrl: string, nextRef: string): string {
38 const url = new URL(currentUrl);
39 url.searchParams.set("ref", nextRef);
40 return url.toString();
41}
42 
43function RefPickerSection({
44 title,
45 kind,
46 currentRef,
47 currentUrl,
48 items,
49 query,
50}: {
51 title: string;
52 kind: "branch" | "tag";
53 currentRef: string;
54 currentUrl: string | null;
55 items: RefItem[];
56 query: string;
57}) {
58 const filtered = items.filter((item) => item.displayName.toLowerCase().includes(query));
59 if (!filtered.length) {
60 return null;
61 }
62 
63 const Icon = kind === "branch" ? GitBranch : Tag;
64 
65 return (
66 <>
67 <div className="mt-1 px-2 py-1 text-xs font-semibold uppercase text-zinc-500 dark:text-zinc-400">
68 {title}
69 </div>
70 {filtered.map((item) => {
71 const raw = decodeSafe(item.name);
72 const isCurrent = raw === currentRef;
73 const href = currentUrl ? buildRefHref(currentUrl, raw) : null;
74 return isCurrent ? (
75 <span
76 key={`${title}-${item.name}`}
77 className="flex items-center gap-2 rounded border border-accent-200 bg-accent-50 px-2 py-1.5 text-accent-700 dark:border-accent-800 dark:bg-accent-900/20 dark:text-accent-300"
78 >
79 <Check className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
80 <Icon
81 className="h-4 w-4 flex-shrink-0 text-zinc-400 dark:text-zinc-500"
82 aria-hidden="true"
83 />
84 <span className="font-medium">{item.displayName}</span>
85 </span>
86 ) : href ? (
87 <a
88 key={`${title}-${item.name}`}
89 href={href}
90 className="flex items-center gap-2 rounded px-2 py-1.5 text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
91 >
92 <span className="h-4 w-4 flex-shrink-0"></span>
93 <Icon
94 className="h-4 w-4 flex-shrink-0 text-zinc-400 dark:text-zinc-500"
95 aria-hidden="true"
96 />
97 <span>{item.displayName}</span>
98 </a>
99 ) : (
100 <span
101 key={`${title}-${item.name}`}
102 className="flex items-center gap-2 rounded px-2 py-1.5 text-zinc-700 dark:text-zinc-300"
103 >
104 <span className="h-4 w-4 flex-shrink-0"></span>
105 <Icon
106 className="h-4 w-4 flex-shrink-0 text-zinc-400 dark:text-zinc-500"
107 aria-hidden="true"
108 />
109 <span>{item.displayName}</span>
110 </span>
111 );
112 })}
113 </>
114 );
115}
116 
117export function RefPickerIsland({ owner, repo, currentRef }: RefPickerProps) {
118 const rootRef = useRef<HTMLDivElement | null>(null);
119 const filterRef = useRef<HTMLInputElement | null>(null);
120 const [currentUrl, setCurrentUrl] = useState<string | null>(null);
121 const [open, setOpen] = useState(false);
122 const [query, setQuery] = useState("");
123 const [loading, setLoading] = useState(true);
124 const [error, setError] = useState<string | null>(null);
125 const [data, setData] = useState<RefApiResponse>({ branches: [], tags: [] });
126 
127 useEffect(() => {
128 setCurrentUrl(window.location.href);
129 }, []);
130 
131 useEffect(() => {
132 let cancelled = false;
133 
134 async function loadRefs() {
135 setLoading(true);
136 setError(null);
137 
138 try {
139 const response = await fetch(`/${owner}/${repo}/api/refs`);
140 if (!response.ok) {
141 throw new Error(`HTTP ${response.status}`);
142 }
143 
144 const nextData = (await response.json()) as RefApiResponse;
145 if (!cancelled) {
146 setData({
147 branches: nextData.branches || [],
148 tags: nextData.tags || [],
149 });
150 }
151 } catch (err) {
152 if (!cancelled) {
153 setError(err instanceof Error ? err.message : "Failed to load refs");
154 }
155 } finally {
156 if (!cancelled) {
157 setLoading(false);
158 }
159 }
160 }
161 
162 void loadRefs();
163 return () => {
164 cancelled = true;
165 };
166 }, [owner, repo]);
167 
168 useEffect(() => {
169 if (!open) {
170 return;
171 }
172 
173 const timeout = window.setTimeout(() => {
174 filterRef.current?.focus();
175 filterRef.current?.select();
176 }, 0);
177 
178 function onPointerDown(event: MouseEvent) {
179 if (!rootRef.current?.contains(event.target as Node)) {
180 setOpen(false);
181 }
182 }
183 
184 function onKeyDown(event: KeyboardEvent) {
185 if (event.key === "Escape") {
186 setOpen(false);
187 }
188 }
189 
190 document.addEventListener("mousedown", onPointerDown);
191 document.addEventListener("keydown", onKeyDown);
192 
193 return () => {
194 window.clearTimeout(timeout);
195 document.removeEventListener("mousedown", onPointerDown);
196 document.removeEventListener("keydown", onKeyDown);
197 };
198 }, [open]);
199 
200 const branches = data.branches || [];
201 const tags = data.tags || [];
202 const queryLower = query.trim().toLowerCase();
203 const hasCurrentRef = useMemo(
204 () => [...branches, ...tags].some((item) => decodeSafe(item.name) === currentRef),
205 [branches, currentRef, tags]
206 );
207 const showCurrentChip =
208 !hasCurrentRef && currentRef && formatRefLabel(currentRef).includes(queryLower);
209 
210 return (
211 <div ref={rootRef} className="relative">
212 <details className="ref-menu relative" open={open}>
213 <summary
214 className={buttonClasses("secondary", "sm")}
215 onClick={(event) => {
216 event.preventDefault();
217 setOpen((value) => !value);
218 }}
219 >
220 <span>{formatRefLabel(currentRef)}</span>
221 </summary>
222 <div className="fixed inset-x-0 z-20 mx-3 mt-2 rounded-xl border border-zinc-200 bg-white p-3 shadow-xl dark:border-zinc-800/60 dark:bg-zinc-900 sm:absolute sm:right-0 sm:left-auto sm:mx-0 sm:w-72 sm:p-2 sm:shadow-md">
223 <input
224 ref={filterRef}
225 type="text"
226 placeholder="Filter branches/tags"
227 value={query}
228 onChange={(event) => setQuery(event.target.value)}
229 className="w-full rounded-lg border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900"
230 autoComplete="off"
231 />
232 <div className="mt-2 max-h-48 overflow-y-auto text-sm sm:max-h-64 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
233 {loading ? (
234 <div className="text-zinc-500 dark:text-zinc-400 px-2 py-2">Loading...</div>
235 ) : null}
236 {!loading && error ? (
237 <div className="text-zinc-500 dark:text-zinc-400 px-2 py-2">{error}</div>
238 ) : null}
239 {!loading && !error ? (
240 <>
241 {showCurrentChip ? (
242 <>
243 <div className="px-2 py-1 text-xs uppercase text-zinc-500 dark:text-zinc-400">
244 Current
245 </div>
246 <span className="flex items-center gap-2 rounded border border-accent-200 bg-accent-50 px-2 py-1.5 text-accent-700 dark:border-accent-800 dark:bg-accent-900/20 dark:text-accent-300">
247 <Check className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
248 <span className="font-medium">
249 {/^[0-9a-f]{40}$/i.test(currentRef)
250 ? `Commit: ${formatRefLabel(currentRef)}`
251 : currentRef}
252 </span>
253 </span>
254 <div className="my-1 border-t border-zinc-200 dark:border-zinc-700"></div>
255 </>
256 ) : null}
257 <RefPickerSection
258 title="Branches"
259 kind="branch"
260 currentRef={currentRef}
261 currentUrl={currentUrl}
262 items={branches}
263 query={queryLower}
264 />
265 <RefPickerSection
266 title="Tags"
267 kind="tag"
268 currentRef={currentRef}
269 currentUrl={currentUrl}
270 items={tags}
271 query={queryLower}
272 />
273 {!showCurrentChip &&
274 !branches.some((item) => item.displayName.toLowerCase().includes(queryLower)) &&
275 !tags.some((item) => item.displayName.toLowerCase().includes(queryLower)) ? (
276 <div className="text-zinc-500 dark:text-zinc-400 px-2 py-2">No refs</div>
277 ) : null}
278 </>
279 ) : null}
280 </div>
281 </div>
282 </details>
283 </div>
284 );
285}
286 
287export function initRefPicker() {
288 hydrateIsland<RefPickerProps>("ref-picker", RefPickerIsland);
289}