History

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

Utkarsh Patil committed 2 months ago

Commit f0fb83a1 parent fe875025 files · +180 −98Browse files

Unified · Split

5 files
  1. apps/web/src/components/Icons.tsx +8 −5
  2. apps/web/src/components/settings/ThemeEditorPanel.tsx +9 −2
  3. apps/web/src/components/settings/ThemeImportDialog.tsx +19 −19
  4. apps/web/src/components/settings/ThemeSearchSection.tsx +139 −67
  5. apps/web/src/components/settings/ThemeSettings.tsx +5 −5
apps/web/src/components/Icons.tsx+8−5
@@ -68,23 +68,26 @@ export const JujutsuIcon: Icon = (props) => { · 67 unmodified lines
6868 );
6969 };
7070
71−export const GitLabIcon: Icon = (props) => (
71+export 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 @@
1−import { ChevronDownIcon, ChevronUpIcon, MousePointer2Icon, PlusIcon, XIcon } from "lucide-react";
1+import {
2+ ChevronDownIcon,
3+ ChevronUpIcon,
4+ MousePointer2Icon,
5+ PaintbrushIcon,
6+ PlusIcon,
7+ XIcon,
8+} from "lucide-react";
29 import {
310 useCallback,
411 useEffect,
@@ -1248,7 +1255,7 @@ export function ThemeEditorPanel({ · 1243 unmodified lines
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 { · 19 unmodified lines
2020 } from "../../vscodeThemeImport";
2121 import { Alert } from "../ui/alert";
2222 import { Button } from "../ui/button";
23−import {
24− Dialog,
25− DialogFooter,
26− DialogHeader,
27− DialogPanel,
28− DialogPopup,
29− DialogTitle,
30−} from "../ui/dialog";
23+import { Dialog, DialogHeader, DialogPanel, DialogPopup, DialogTitle } from "../ui/dialog";
3124 import { ThemeSearchSection } from "./ThemeSearchSection";
3225
3326 /**
@@ -131,8 +124,8 @@ function ThemeJsonEditor({ · 97 unmodified lines
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({ · 374 unmodified lines
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({ · 17 unmodified lines
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({ · 4 unmodified lines
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 @@
11 import {
22 ExternalLinkIcon,
3+ PackagePlusIcon,
34 PaletteIcon,
4− PlusIcon,
55 RefreshCwIcon,
66 SearchIcon,
7− ShieldCheckIcon,
87 } from "lucide-react";
9−import type { FormEvent } from "react";
108 import { useCallback, useEffect, useRef, useState } from "react";
119 import {
1210 importOpenVsxThemeExtension,
@@ -14,12 +12,14 @@ import { · 1 unmodified line
1412 type OpenVsxThemeExtension,
1513 type OpenVsxThemeSort,
1614 } from "../../openVsxThemes";
15+import { useDebouncedValue } from "../../state/queries";
1716 import {
1817 getCustomThemes,
1918 getStoredCustomThemeCollection,
2019 replaceCustomThemeCollection,
2120 type ThemeDefinition,
2221 } from "../../themePalette";
22+import { GitHubIcon, GitLabIcon } from "../Icons";
2323 import {
2424 AlertDialog,
2525 AlertDialogClose,
@@ -45,6 +45,20 @@ const SORT_OPTIONS: ReadonlyArray<{ value: OpenVsxThemeSort; label: string }> = · 19 unmodified lines
4545 { value: "timestamp", label: "Newest" },
4646 { value: "relevance", label: "Most relevant" },
4747 ];
48+const SEARCH_DEBOUNCE_MS = 350;
49+
50+function 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
4963 function ThemeExtensionIcon({ extension }: { extension: OpenVsxThemeExtension }) {
5064 const [failed, setFailed] = useState(false);
@@ -82,11 +96,21 @@ export function ThemeSearchSection({ · 31 unmodified lines
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({ · 15 unmodified lines
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({ · 5 unmodified lines
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({ · 14 unmodified lines
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({ · 26 unmodified lines
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({ · 27 unmodified lines
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({ · 8 unmodified lines
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({ · 10 unmodified lines
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({ · 14 unmodified lines
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({ · 3 unmodified lines
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 @@
11 import {
22 CheckIcon,
33 CopyIcon,
4− DownloadIcon,
54 MoonIcon,
5+ PaintbrushIcon,
66 PenLineIcon,
77 PlusIcon,
88 SunIcon,
@@ -768,7 +768,7 @@ export function ThemeLibrary({ · 759 unmodified lines
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({ · 95 unmodified lines
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>