authorgravatar for 73941998+UtkarshUsername@users.noreply.github.comUtkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> 2026-08-21 03:52:30+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-20 18:22:30-04:00
logf0fb83aff1be5d093225cda511df1216cef748d2
treeb91365c9bbc46801bb5831017ed0ac8855b0829d
parentfe87502084ad99209a22f801347102ebb29cc65b
signature Signed by PGP key B5690EEEBB952194

fix(web): polish theme library buttons, search, and import dialog (#7580)


5 files changed, 180 insertions(+), 98 deletions(-)

apps/web/src/components/Icons.tsx+8-5
......@@ -68,23 +68,26 @@ export const JujutsuIcon: Icon = (props) => {
6868 );
6969};
7070
71export const GitLabIcon: Icon = (props) => (
71export const GitLabIcon = ({
72 monochrome = false,
73 ...props
74}: SVGProps<SVGSVGElement> & { readonly monochrome?: boolean }) => (
7275 <svg {...props} viewBox="0 0 32 32" fill="none">
7376 <path
7477 d="m31.46 12.78-.04-.12-4.35-11.35A1.14 1.14 0 0 0 25.94.6c-.24 0-.47.1-.66.24-.19.15-.33.36-.39.6l-2.94 9h-11.9l-2.94-9A1.14 1.14 0 0 0 6.07.58a1.15 1.15 0 0 0-1.14.72L.58 12.68l-.05.11a8.1 8.1 0 0 0 2.68 9.34l.02.01.04.03 6.63 4.97 3.28 2.48 2 1.52a1.35 1.35 0 0 0 1.62 0l2-1.52 3.28-2.48 6.67-5h.02a8.09 8.09 0 0 0 2.7-9.36Z"
75 fill="#E24329"
78 fill={monochrome ? "currentColor" : "#E24329"}
7679 />
7780 <path
7881 d="m31.46 12.78-.04-.12a14.75 14.75 0 0 0-5.86 2.64l-9.55 7.24 6.09 4.6 6.67-5h.02a8.09 8.09 0 0 0 2.67-9.36Z"
79 fill="#FC6D26"
82 fill={monochrome ? "currentColor" : "#FC6D26"}
8083 />
8184 <path
8285 d="m9.9 27.14 3.28 2.48 2 1.52a1.35 1.35 0 0 0 1.62 0l2-1.52 3.28-2.48-6.1-4.6-6.07 4.6Z"
83 fill="#FCA326"
86 fill={monochrome ? "currentColor" : "#FCA326"}
8487 />
8588 <path
8689 d="M6.44 15.3a14.71 14.71 0 0 0-5.86-2.63l-.05.12a8.1 8.1 0 0 0 2.68 9.34l.02.01.04.03 6.63 4.97 6.1-4.6-9.56-7.24Z"
87 fill="#FC6D26"
90 fill={monochrome ? "currentColor" : "#FC6D26"}
8891 />
8992 </svg>
9093);
apps/web/src/components/settings/ThemeEditorPanel.tsx+9-2
......@@ -1,4 +1,11 @@
1import { ChevronDownIcon, ChevronUpIcon, MousePointer2Icon, PlusIcon, XIcon } from "lucide-react";
1import {
2 ChevronDownIcon,
3 ChevronUpIcon,
4 MousePointer2Icon,
5 PaintbrushIcon,
6 PlusIcon,
7 XIcon,
8} from "lucide-react";
29import {
310 useCallback,
411 useEffect,
......@@ -1248,7 +1255,7 @@ export function ThemeEditorPanel({
12481255 </>
12491256 ) : (
12501257 <>
1251 <PlusIcon />
1258 <PaintbrushIcon />
12521259 Create theme
12531260 </>
12541261 )}
apps/web/src/components/settings/ThemeImportDialog.tsx+19-19
......@@ -20,14 +20,7 @@ import {
2020} from "../../vscodeThemeImport";
2121import { Alert } from "../ui/alert";
2222import { Button } from "../ui/button";
23import {
24 Dialog,
25 DialogFooter,
26 DialogHeader,
27 DialogPanel,
28 DialogPopup,
29 DialogTitle,
30} from "../ui/dialog";
23import { Dialog, DialogHeader, DialogPanel, DialogPopup, DialogTitle } from "../ui/dialog";
3124import { ThemeSearchSection } from "./ThemeSearchSection";
3225
3326/**
......@@ -131,8 +124,8 @@ function ThemeJsonEditor({
131124 <textarea
132125 aria-label="Theme JSON"
133126 className={cn(
134 "relative z-10 block min-h-72 w-full resize-y overflow-auto bg-transparent p-3 font-mono text-[12px] leading-5 caret-foreground outline-none placeholder:text-muted-foreground selection:bg-accent/30",
135 isPlainText ? "text-foreground" : "text-transparent selection:text-transparent",
127 "relative z-10 block min-h-44 w-full resize-y overflow-auto bg-transparent p-3 font-mono text-[12px] leading-5 caret-foreground outline-none placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground",
128 isPlainText ? "text-foreground" : "text-transparent",
136129 )}
137130 id={id}
138131 onChange={(event) => onChange(event.currentTarget.value)}
......@@ -513,6 +506,9 @@ export function ThemeImportDialog({
513506 <Button size="sm" variant="ghost" onClick={() => setConflicts(null)}>
514507 Back
515508 </Button>
509 <Button size="sm" variant="ghost" onClick={() => onOpenChange(false)}>
510 Cancel
511 </Button>
516512 </div>
517513 </div>
518514 );
......@@ -536,6 +532,19 @@ export function ThemeImportDialog({
536532 {fileInput}
537533 </div>
538534 {editorSection()}
535 {/* The actions live with the import section, not in a DialogFooter,
536 because Add theme only applies to the file in this section. Pinning
537 them at the modal bottom would read as a modal-scoped action when
538 the dialog also has the search and conflict views. */}
539 <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
540 <Button variant="ghost" onClick={() => onOpenChange(false)}>
541 Cancel
542 </Button>
543 <Button disabled={!json.trim() || isReading} onClick={handleSubmit}>
544 <PlusIcon />
545 Add theme
546 </Button>
547 </div>
539548 </div>
540549 );
541550 })()}
......@@ -546,15 +555,6 @@ export function ThemeImportDialog({
546555 </Alert>
547556 ) : null}
548557 </DialogPanel>
549 <DialogFooter>
550 <Button variant="ghost" onClick={() => onOpenChange(false)}>
551 Cancel
552 </Button>
553 <Button disabled={!json.trim() || isReading} onClick={handleSubmit}>
554 <PlusIcon />
555 Add theme
556 </Button>
557 </DialogFooter>
558558 </DialogPopup>
559559 </Dialog>
560560 );
apps/web/src/components/settings/ThemeSearchSection.tsx+139-67
......@@ -1,12 +1,10 @@
11import {
22 ExternalLinkIcon,
3 PackagePlusIcon,
34 PaletteIcon,
4 PlusIcon,
55 RefreshCwIcon,
66 SearchIcon,
7 ShieldCheckIcon,
87} from "lucide-react";
9import type { FormEvent } from "react";
108import { useCallback, useEffect, useRef, useState } from "react";
119import {
1210 importOpenVsxThemeExtension,
......@@ -14,12 +12,14 @@ import {
1412 type OpenVsxThemeExtension,
1513 type OpenVsxThemeSort,
1614} from "../../openVsxThemes";
15import { useDebouncedValue } from "../../state/queries";
1716import {
1817 getCustomThemes,
1918 getStoredCustomThemeCollection,
2019 replaceCustomThemeCollection,
2120 type ThemeDefinition,
2221} from "../../themePalette";
22import { GitHubIcon, GitLabIcon } from "../Icons";
2323import {
2424 AlertDialog,
2525 AlertDialogClose,
......@@ -45,6 +45,20 @@ const SORT_OPTIONS: ReadonlyArray<{ value: OpenVsxThemeSort; label: string }> =
4545 { value: "timestamp", label: "Newest" },
4646 { value: "relevance", label: "Most relevant" },
4747];
48const SEARCH_DEBOUNCE_MS = 350;
49
50function SourceLinkIcon({ url }: { url: string }) {
51 try {
52 const host = new URL(url).hostname.toLowerCase();
53 if (host === "github.com" || host.endsWith(".github.com"))
54 return <GitHubIcon className="size-3.5" />;
55 if (host === "gitlab.com" || host.endsWith(".gitlab.com"))
56 return <GitLabIcon className="size-3.5" monochrome />;
57 } catch {
58 // Fall through to the generic external-link icon.
59 }
60 return <ExternalLinkIcon className="size-3.5" />;
61}
4862
4963function ThemeExtensionIcon({ extension }: { extension: OpenVsxThemeExtension }) {
5064 const [failed, setFailed] = useState(false);
......@@ -82,11 +96,21 @@ export function ThemeSearchSection({
8296 const [installingId, setInstallingId] = useState<string | null>(null);
8397 const [pendingUpdate, setPendingUpdate] = useState<OpenVsxThemeExtension | null>(null);
8498 const requestRef = useRef<AbortController | null>(null);
99 // The (query, sort) pair the last search actually ran, so an install
100 // finishing can tell a same-key rerun (which must not wipe an install
101 // error) from a query that changed mid-install (which must be searched).
102 const lastSearchKeyRef = useRef<string | null>(null);
103 // The (query, sort) pair from the previous effect run, so a search error
104 // that belongs to an older key can be cleared when the user returns to
105 // already-shown results without clearing a fresh install error.
106 const prevSearchKeyRef = useRef<string | null>(null);
85107
86108 useEffect(() => {
87109 requestRef.current?.abort();
88110 requestRef.current = null;
89111 if (open) {
112 lastSearchKeyRef.current = null;
113 prevSearchKeyRef.current = null;
90114 setQuery("");
91115 setSortBy("downloadCount");
92116 setResults(null);
......@@ -108,18 +132,21 @@ export function ThemeSearchSection({
108132 requestRef.current?.abort();
109133 const controller = new AbortController();
110134 requestRef.current = controller;
111 setQuery(trimmed);
112 setIsSearching(true);
113135 setError(null);
114 setResults(null);
136 setIsSearching(true);
115137 try {
116138 const nextResults = await searchOpenVsxThemes(trimmed, {
117139 signal: controller.signal,
118140 sortBy: nextSort,
119141 });
120 if (!controller.signal.aborted) setResults(nextResults);
142 if (!controller.signal.aborted) {
143 setResults(nextResults);
144 lastSearchKeyRef.current = `${trimmed}\u0000${nextSort}`;
145 }
121146 } catch (cause) {
122147 if (!controller.signal.aborted) {
148 setResults(null);
149 lastSearchKeyRef.current = null;
123150 setError(cause instanceof Error ? cause.message : "Open VSX search failed.");
124151 }
125152 }
......@@ -131,25 +158,66 @@ export function ThemeSearchSection({
131158 [sortBy],
132159 );
133160
134 const handleSearch = useCallback(
135 (event: FormEvent<HTMLFormElement>) => {
136 event.preventDefault();
137 void runSearch(query);
138 },
139 [query, runSearch],
140 );
161 const debouncedQuery = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
141162
142 const handleSortChange = useCallback(
143 (value: OpenVsxThemeSort | null) => {
144 const nextSort = SORT_OPTIONS.find((option) => option.value === value)?.value;
145 if (!nextSort) return;
146 setSortBy(nextSort);
147 if ((results !== null || isSearching) && query.trim()) {
148 void runSearch(query, nextSort);
149 }
150 },
151 [isSearching, query, results, runSearch],
152 );
163 useEffect(() => {
164 if (query.trim() || installingId !== null) return;
165 requestRef.current?.abort();
166 requestRef.current = null;
167 lastSearchKeyRef.current = null;
168 setResults(null);
169 setError(null);
170 setIsSearching(false);
171 }, [query, installingId]);
172
173 useEffect(() => {
174 if (!open) return;
175 const searchKey = `${debouncedQuery}\u0000${sortBy}`;
176 const keyChanged = prevSearchKeyRef.current !== searchKey;
177 prevSearchKeyRef.current = searchKey;
178 if (installingId !== null) return;
179 if (!debouncedQuery) {
180 lastSearchKeyRef.current = null;
181 requestRef.current?.abort();
182 requestRef.current = null;
183 setResults(null);
184 setError(null);
185 setIsSearching(false);
186 return;
187 }
188 if (debouncedQuery !== query.trim()) {
189 // The debounced value still trails the input (dialog reopened with the
190 // box reset, or the user is mid-keystroke). Searching it would hit Open
191 // VSX for a query that is no longer visible; wait for the debounce to
192 // catch up to the current input instead.
193 return;
194 }
195 if (lastSearchKeyRef.current === searchKey) {
196 // The results already match this query. A request for a newer key may
197 // still be in flight (typed and then undone); abort it so it cannot
198 // overwrite the results. Only a genuine key change makes a stale search
199 // error irrelevant, so an install error on an unchanged query survives.
200 requestRef.current?.abort();
201 requestRef.current = null;
202 setIsSearching(false);
203 if (keyChanged) setError(null);
204 return;
205 }
206 void runSearch(debouncedQuery);
207 // `installingId` and `sortBy` are deliberately not dependencies: the
208 // guards above read the current values from the fresh render closure. An
209 // install finishing reruns the search only when the query or sort changed
210 // while it was in flight (checked via lastSearchKeyRef, recorded only
211 // once a search succeeds), so the install error the user needs to see is
212 // preserved across that rerun.
213 // eslint-disable-next-line react-hooks/exhaustive-deps
214 }, [open, query, debouncedQuery, installingId, runSearch]);
215
216 const handleSortChange = useCallback((value: OpenVsxThemeSort | null) => {
217 const nextSort = SORT_OPTIONS.find((option) => option.value === value)?.value;
218 if (!nextSort) return;
219 setSortBy(nextSort);
220 }, []);
153221
154222 const handleInstall = useCallback(
155223 async (extension: OpenVsxThemeExtension, allowUpdate: boolean) => {
......@@ -170,6 +238,7 @@ export function ThemeSearchSection({
170238 requestRef.current?.abort();
171239 const controller = new AbortController();
172240 requestRef.current = controller;
241 setIsSearching(false);
173242 setInstallingId(extension.id);
174243 try {
175244 const themes = await importOpenVsxThemeExtension(extension, controller.signal);
......@@ -202,42 +271,43 @@ export function ThemeSearchSection({
202271 Find open-source themes from Open VSX.
203272 </p>
204273 </div>
205 <form className="flex gap-2" onSubmit={handleSearch}>
206 <InputGroup>
207 <InputGroupAddon>
208 <SearchIcon />
209 </InputGroupAddon>
210 <InputGroupInput
211 aria-label="Search Open VSX themes"
212 autoFocus
213 onChange={(event) => setQuery(event.currentTarget.value)}
214 placeholder="try dracula, nord, catppuccin..."
215 size="lg"
216 type="search"
217 value={query}
218 />
219 </InputGroup>
220 <Button
221 disabled={!query.trim() || isSearching || installingId !== null}
274 <InputGroup>
275 <InputGroupAddon>
276 {isSearching ? <Spinner aria-hidden /> : <SearchIcon aria-hidden />}
277 </InputGroupAddon>
278 <InputGroupInput
279 aria-label="Search Open VSX themes"
280 autoFocus
281 onChange={(event) => setQuery(event.currentTarget.value)}
282 onKeyDown={(event) => {
283 if (event.nativeEvent.isComposing || event.keyCode === 229) return;
284 if (event.key === "Enter" && !isSearching && installingId === null)
285 void runSearch(query.trim());
286 }}
287 placeholder="Search themes..."
222288 size="lg"
223 type="submit"
224 variant="outline"
225 >
226 {isSearching ? <Spinner /> : <SearchIcon />}
227 Search
228 </Button>
229 </form>
289 type="search"
290 value={query}
291 />
292 </InputGroup>
230293
231 {!isSearching ? (
294 {!isSearching || results !== null ? (
232295 <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
233296 <div className="flex min-w-0 flex-wrap items-center gap-1.5">
234297 <p className="text-muted-foreground text-xs">Popular</p>
235298 {SUGGESTED_SEARCHES.map((suggestion) => (
236299 <Button
237300 key={suggestion}
301 disabled={installingId !== null}
238302 size="xs"
239303 variant="ghost"
240 onClick={() => void runSearch(suggestion)}
304 onClick={() => {
305 if (query.trim() === suggestion) {
306 void runSearch(suggestion);
307 } else {
308 setQuery(suggestion);
309 }
310 }}
241311 >
242312 {suggestion}
243313 </Button>
......@@ -271,7 +341,7 @@ export function ThemeSearchSection({
271341
272342 <div className="sr-only" role="status">
273343 {isSearching
274 ? "Finding themes..."
344 ? "Searching themes..."
275345 : results
276346 ? `${results.length} supported ${results.length === 1 ? "theme" : "themes"} found.`
277347 : ""}
......@@ -286,9 +356,9 @@ export function ThemeSearchSection({
286356 </div>
287357 ) : null}
288358
289 {isSearching ? (
359 {isSearching && results === null ? (
290360 <div className="flex min-h-20 items-center justify-center gap-2 text-muted-foreground text-sm">
291 <Spinner /> Finding themes...
361 <Spinner /> Searching themes...
292362 </div>
293363 ) : null}
294364
......@@ -305,8 +375,8 @@ export function ThemeSearchSection({
305375 const isInstalled = getCustomThemes().some(
306376 (theme) => theme.collection?.id === extension.collectionId,
307377 );
308 const action = isInstalled ? "Update" : "Add";
309 const progressAction = isInstalled ? "Updating" : "Adding";
378 const action = isInstalled ? "Update" : "Install";
379 const progressAction = isInstalled ? "Updating" : "Installing";
310380 return (
311381 <article
312382 className="group flex min-w-0 flex-col gap-3 rounded-xl border border-border/70 bg-card/60 p-3 transition-colors hover:bg-accent/20"
......@@ -327,19 +397,15 @@ export function ThemeSearchSection({
327397 </p>
328398 <div className="mt-auto flex items-center justify-between gap-2">
329399 <div className="flex min-w-0 items-center gap-2">
330 <span className="inline-flex items-center gap-1 text-muted-foreground text-[11px]">
331 <ShieldCheckIcon className="size-3" /> {extension.license}
332 </span>
333400 {extension.sourceUrl ? (
334 <a
401 <Button
335402 aria-label={`View source for ${extension.name}`}
336 className="inline-flex items-center gap-1 text-muted-foreground text-[11px] hover:text-foreground"
337 href={extension.sourceUrl}
338 rel="noreferrer"
339 target="_blank"
403 render={<a href={extension.sourceUrl} rel="noreferrer" target="_blank" />}
404 size="icon-micro"
405 variant="ghost-muted"
340406 >
341 Source <ExternalLinkIcon className="size-3" />
342 </a>
407 <SourceLinkIcon url={extension.sourceUrl} />
408 </Button>
343409 ) : null}
344410 </div>
345411 <Button
......@@ -349,7 +415,13 @@ export function ThemeSearchSection({
349415 variant="outline"
350416 onClick={() => void handleInstall(extension, false)}
351417 >
352 {isInstalling ? <Spinner /> : isInstalled ? <RefreshCwIcon /> : <PlusIcon />}
418 {isInstalling ? (
419 <Spinner />
420 ) : isInstalled ? (
421 <RefreshCwIcon />
422 ) : (
423 <PackagePlusIcon />
424 )}
353425 {isInstalling ? `${progressAction}...` : action}
354426 </Button>
355427 </div>
apps/web/src/components/settings/ThemeSettings.tsx+5-5
......@@ -1,8 +1,8 @@
11import {
22 CheckIcon,
33 CopyIcon,
4 DownloadIcon,
54 MoonIcon,
5 PaintbrushIcon,
66 PenLineIcon,
77 PlusIcon,
88 SunIcon,
......@@ -768,7 +768,7 @@ export function ThemeLibrary({
768768 <TooltipProvider>
769769 <div
770770 className="mx-auto grid w-full max-w-[56rem] gap-2 px-3 sm:px-4"
771 style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 17rem), 1fr))" }}
771 style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 16rem), 1fr))" }}
772772 >
773773 {STANDARD_THEME_CARDS.map((standardTheme) => (
774774 <ThemeLibraryCard
......@@ -870,12 +870,12 @@ export function ThemeLibrary({
870870 })
871871 }
872872 >
873 <PlusIcon />
873 <PaintbrushIcon />
874874 Create theme
875875 </Button>
876876 <Button size="xs" variant="outline" onClick={() => onImportOpenChange(true)}>
877 <DownloadIcon />
878 Import theme
877 <PlusIcon />
878 Add theme
879879 </Button>
880880 </div>
881881 </div>