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) => {...@@ -68,23 +68,26 @@ export const JujutsuIcon: Icon = (props) => {
68 );68 );
69};69};
7070
71export const GitLabIcon: Icon = (props) => (71export const GitLabIcon = ({
72 monochrome = false,
73 ...props
74}: SVGProps<SVGSVGElement> & { readonly monochrome?: boolean }) => (
72 <svg {...props} viewBox="0 0 32 32" fill="none">75 <svg {...props} viewBox="0 0 32 32" fill="none">
73 <path76 <path
74 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"77 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"}
76 />79 />
77 <path80 <path
78 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"81 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"}
80 />83 />
81 <path84 <path
82 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"85 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"}
84 />87 />
85 <path88 <path
86 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"89 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"}
88 />91 />
89 </svg>92 </svg>
90);93);
apps/web/src/components/settings/ThemeEditorPanel.tsx+9-2
...@@ -1,4 +1,11 @@...@@ -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";
2import {9import {
3 useCallback,10 useCallback,
4 useEffect,11 useEffect,
...@@ -1248,7 +1255,7 @@ export function ThemeEditorPanel({...@@ -1248,7 +1255,7 @@ export function ThemeEditorPanel({
1248 </>1255 </>
1249 ) : (1256 ) : (
1250 <>1257 <>
1251 <PlusIcon />1258 <PaintbrushIcon />
1252 Create theme1259 Create theme
1253 </>1260 </>
1254 )}1261 )}
apps/web/src/components/settings/ThemeImportDialog.tsx+19-19
...@@ -20,14 +20,7 @@ import {...@@ -20,14 +20,7 @@ import {
20} from "../../vscodeThemeImport";20} from "../../vscodeThemeImport";
21import { Alert } from "../ui/alert";21import { Alert } from "../ui/alert";
22import { Button } from "../ui/button";22import { Button } from "../ui/button";
23import {23import { Dialog, DialogHeader, DialogPanel, DialogPopup, DialogTitle } from "../ui/dialog";
24 Dialog,
25 DialogFooter,
26 DialogHeader,
27 DialogPanel,
28 DialogPopup,
29 DialogTitle,
30} from "../ui/dialog";
31import { ThemeSearchSection } from "./ThemeSearchSection";24import { ThemeSearchSection } from "./ThemeSearchSection";
3225
33/**26/**
...@@ -131,8 +124,8 @@ function ThemeJsonEditor({...@@ -131,8 +124,8 @@ function ThemeJsonEditor({
131 <textarea124 <textarea
132 aria-label="Theme JSON"125 aria-label="Theme JSON"
133 className={cn(126 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",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",
135 isPlainText ? "text-foreground" : "text-transparent selection:text-transparent",128 isPlainText ? "text-foreground" : "text-transparent",
136 )}129 )}
137 id={id}130 id={id}
138 onChange={(event) => onChange(event.currentTarget.value)}131 onChange={(event) => onChange(event.currentTarget.value)}
...@@ -513,6 +506,9 @@ export function ThemeImportDialog({...@@ -513,6 +506,9 @@ export function ThemeImportDialog({
513 <Button size="sm" variant="ghost" onClick={() => setConflicts(null)}>506 <Button size="sm" variant="ghost" onClick={() => setConflicts(null)}>
514 Back507 Back
515 </Button>508 </Button>
509 <Button size="sm" variant="ghost" onClick={() => onOpenChange(false)}>
510 Cancel
511 </Button>
516 </div>512 </div>
517 </div>513 </div>
518 );514 );
...@@ -536,6 +532,19 @@ export function ThemeImportDialog({...@@ -536,6 +532,19 @@ export function ThemeImportDialog({
536 {fileInput}532 {fileInput}
537 </div>533 </div>
538 {editorSection()}534 {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>
539 </div>548 </div>
540 );549 );
541 })()}550 })()}
...@@ -546,15 +555,6 @@ export function ThemeImportDialog({...@@ -546,15 +555,6 @@ export function ThemeImportDialog({
546 </Alert>555 </Alert>
547 ) : null}556 ) : null}
548 </DialogPanel>557 </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>
558 </DialogPopup>558 </DialogPopup>
559 </Dialog>559 </Dialog>
560 );560 );
apps/web/src/components/settings/ThemeSearchSection.tsx+139-67
...@@ -1,12 +1,10 @@...@@ -1,12 +1,10 @@
1import {1import {
2 ExternalLinkIcon,2 ExternalLinkIcon,
3 PackagePlusIcon,
3 PaletteIcon,4 PaletteIcon,
4 PlusIcon,
5 RefreshCwIcon,5 RefreshCwIcon,
6 SearchIcon,6 SearchIcon,
7 ShieldCheckIcon,
8} from "lucide-react";7} from "lucide-react";
9import type { FormEvent } from "react";
10import { useCallback, useEffect, useRef, useState } from "react";8import { useCallback, useEffect, useRef, useState } from "react";
11import {9import {
12 importOpenVsxThemeExtension,10 importOpenVsxThemeExtension,
...@@ -14,12 +12,14 @@ import {...@@ -14,12 +12,14 @@ import {
14 type OpenVsxThemeExtension,12 type OpenVsxThemeExtension,
15 type OpenVsxThemeSort,13 type OpenVsxThemeSort,
16} from "../../openVsxThemes";14} from "../../openVsxThemes";
15import { useDebouncedValue } from "../../state/queries";
17import {16import {
18 getCustomThemes,17 getCustomThemes,
19 getStoredCustomThemeCollection,18 getStoredCustomThemeCollection,
20 replaceCustomThemeCollection,19 replaceCustomThemeCollection,
21 type ThemeDefinition,20 type ThemeDefinition,
22} from "../../themePalette";21} from "../../themePalette";
22import { GitHubIcon, GitLabIcon } from "../Icons";
23import {23import {
24 AlertDialog,24 AlertDialog,
25 AlertDialogClose,25 AlertDialogClose,
...@@ -45,6 +45,20 @@ const SORT_OPTIONS: ReadonlyArray<{ value: OpenVsxThemeSort; label: string }> =...@@ -45,6 +45,20 @@ const SORT_OPTIONS: ReadonlyArray<{ value: OpenVsxThemeSort; label: string }> =
45 { value: "timestamp", label: "Newest" },45 { value: "timestamp", label: "Newest" },
46 { value: "relevance", label: "Most relevant" },46 { value: "relevance", label: "Most relevant" },
47];47];
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
49function ThemeExtensionIcon({ extension }: { extension: OpenVsxThemeExtension }) {63function ThemeExtensionIcon({ extension }: { extension: OpenVsxThemeExtension }) {
50 const [failed, setFailed] = useState(false);64 const [failed, setFailed] = useState(false);
...@@ -82,11 +96,21 @@ export function ThemeSearchSection({...@@ -82,11 +96,21 @@ export function ThemeSearchSection({
82 const [installingId, setInstallingId] = useState<string | null>(null);96 const [installingId, setInstallingId] = useState<string | null>(null);
83 const [pendingUpdate, setPendingUpdate] = useState<OpenVsxThemeExtension | null>(null);97 const [pendingUpdate, setPendingUpdate] = useState<OpenVsxThemeExtension | null>(null);
84 const requestRef = useRef<AbortController | null>(null);98 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
86 useEffect(() => {108 useEffect(() => {
87 requestRef.current?.abort();109 requestRef.current?.abort();
88 requestRef.current = null;110 requestRef.current = null;
89 if (open) {111 if (open) {
112 lastSearchKeyRef.current = null;
113 prevSearchKeyRef.current = null;
90 setQuery("");114 setQuery("");
91 setSortBy("downloadCount");115 setSortBy("downloadCount");
92 setResults(null);116 setResults(null);
...@@ -108,18 +132,21 @@ export function ThemeSearchSection({...@@ -108,18 +132,21 @@ export function ThemeSearchSection({
108 requestRef.current?.abort();132 requestRef.current?.abort();
109 const controller = new AbortController();133 const controller = new AbortController();
110 requestRef.current = controller;134 requestRef.current = controller;
111 setQuery(trimmed);
112 setIsSearching(true);
113 setError(null);135 setError(null);
114 setResults(null);136 setIsSearching(true);
115 try {137 try {
116 const nextResults = await searchOpenVsxThemes(trimmed, {138 const nextResults = await searchOpenVsxThemes(trimmed, {
117 signal: controller.signal,139 signal: controller.signal,
118 sortBy: nextSort,140 sortBy: nextSort,
119 });141 });
120 if (!controller.signal.aborted) setResults(nextResults);142 if (!controller.signal.aborted) {
143 setResults(nextResults);
144 lastSearchKeyRef.current = `${trimmed}\u0000${nextSort}`;
145 }
121 } catch (cause) {146 } catch (cause) {
122 if (!controller.signal.aborted) {147 if (!controller.signal.aborted) {
148 setResults(null);
149 lastSearchKeyRef.current = null;
123 setError(cause instanceof Error ? cause.message : "Open VSX search failed.");150 setError(cause instanceof Error ? cause.message : "Open VSX search failed.");
124 }151 }
125 }152 }
...@@ -131,25 +158,66 @@ export function ThemeSearchSection({...@@ -131,25 +158,66 @@ export function ThemeSearchSection({
131 [sortBy],158 [sortBy],
132 );159 );
133160
134 const handleSearch = useCallback(161 const debouncedQuery = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
135 (event: FormEvent<HTMLFormElement>) => {
136 event.preventDefault();
137 void runSearch(query);
138 },
139 [query, runSearch],
140 );
141162
142 const handleSortChange = useCallback(163 useEffect(() => {
143 (value: OpenVsxThemeSort | null) => {164 if (query.trim() || installingId !== null) return;
144 const nextSort = SORT_OPTIONS.find((option) => option.value === value)?.value;165 requestRef.current?.abort();
145 if (!nextSort) return;166 requestRef.current = null;
146 setSortBy(nextSort);167 lastSearchKeyRef.current = null;
147 if ((results !== null || isSearching) && query.trim()) {168 setResults(null);
148 void runSearch(query, nextSort);169 setError(null);
149 }170 setIsSearching(false);
150 },171 }, [query, installingId]);
151 [isSearching, query, results, runSearch],172
152 );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
154 const handleInstall = useCallback(222 const handleInstall = useCallback(
155 async (extension: OpenVsxThemeExtension, allowUpdate: boolean) => {223 async (extension: OpenVsxThemeExtension, allowUpdate: boolean) => {
...@@ -170,6 +238,7 @@ export function ThemeSearchSection({...@@ -170,6 +238,7 @@ export function ThemeSearchSection({
170 requestRef.current?.abort();238 requestRef.current?.abort();
171 const controller = new AbortController();239 const controller = new AbortController();
172 requestRef.current = controller;240 requestRef.current = controller;
241 setIsSearching(false);
173 setInstallingId(extension.id);242 setInstallingId(extension.id);
174 try {243 try {
175 const themes = await importOpenVsxThemeExtension(extension, controller.signal);244 const themes = await importOpenVsxThemeExtension(extension, controller.signal);
...@@ -202,42 +271,43 @@ export function ThemeSearchSection({...@@ -202,42 +271,43 @@ export function ThemeSearchSection({
202 Find open-source themes from Open VSX.271 Find open-source themes from Open VSX.
203 </p>272 </p>
204 </div>273 </div>
205 <form className="flex gap-2" onSubmit={handleSearch}>274 <InputGroup>
206 <InputGroup>275 <InputGroupAddon>
207 <InputGroupAddon>276 {isSearching ? <Spinner aria-hidden /> : <SearchIcon aria-hidden />}
208 <SearchIcon />277 </InputGroupAddon>
209 </InputGroupAddon>278 <InputGroupInput
210 <InputGroupInput279 aria-label="Search Open VSX themes"
211 aria-label="Search Open VSX themes"280 autoFocus
212 autoFocus281 onChange={(event) => setQuery(event.currentTarget.value)}
213 onChange={(event) => setQuery(event.currentTarget.value)}282 onKeyDown={(event) => {
214 placeholder="try dracula, nord, catppuccin..."283 if (event.nativeEvent.isComposing || event.keyCode === 229) return;
215 size="lg"284 if (event.key === "Enter" && !isSearching && installingId === null)
216 type="search"285 void runSearch(query.trim());
217 value={query}286 }}
218 />287 placeholder="Search themes..."
219 </InputGroup>
220 <Button
221 disabled={!query.trim() || isSearching || installingId !== null}
222 size="lg"288 size="lg"
223 type="submit"289 type="search"
224 variant="outline"290 value={query}
225 >291 />
226 {isSearching ? <Spinner /> : <SearchIcon />}292 </InputGroup>
227 Search
228 </Button>
229 </form>
230293
231 {!isSearching ? (294 {!isSearching || results !== null ? (
232 <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">295 <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
233 <div className="flex min-w-0 flex-wrap items-center gap-1.5">296 <div className="flex min-w-0 flex-wrap items-center gap-1.5">
234 <p className="text-muted-foreground text-xs">Popular</p>297 <p className="text-muted-foreground text-xs">Popular</p>
235 {SUGGESTED_SEARCHES.map((suggestion) => (298 {SUGGESTED_SEARCHES.map((suggestion) => (
236 <Button299 <Button
237 key={suggestion}300 key={suggestion}
301 disabled={installingId !== null}
238 size="xs"302 size="xs"
239 variant="ghost"303 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 }}
241 >311 >
242 {suggestion}312 {suggestion}
243 </Button>313 </Button>
...@@ -271,7 +341,7 @@ export function ThemeSearchSection({...@@ -271,7 +341,7 @@ export function ThemeSearchSection({
271341
272 <div className="sr-only" role="status">342 <div className="sr-only" role="status">
273 {isSearching343 {isSearching
274 ? "Finding themes..."344 ? "Searching themes..."
275 : results345 : results
276 ? `${results.length} supported ${results.length === 1 ? "theme" : "themes"} found.`346 ? `${results.length} supported ${results.length === 1 ? "theme" : "themes"} found.`
277 : ""}347 : ""}
...@@ -286,9 +356,9 @@ export function ThemeSearchSection({...@@ -286,9 +356,9 @@ export function ThemeSearchSection({
286 </div>356 </div>
287 ) : null}357 ) : null}
288358
289 {isSearching ? (359 {isSearching && results === null ? (
290 <div className="flex min-h-20 items-center justify-center gap-2 text-muted-foreground text-sm">360 <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...
292 </div>362 </div>
293 ) : null}363 ) : null}
294364
...@@ -305,8 +375,8 @@ export function ThemeSearchSection({...@@ -305,8 +375,8 @@ export function ThemeSearchSection({
305 const isInstalled = getCustomThemes().some(375 const isInstalled = getCustomThemes().some(
306 (theme) => theme.collection?.id === extension.collectionId,376 (theme) => theme.collection?.id === extension.collectionId,
307 );377 );
308 const action = isInstalled ? "Update" : "Add";378 const action = isInstalled ? "Update" : "Install";
309 const progressAction = isInstalled ? "Updating" : "Adding";379 const progressAction = isInstalled ? "Updating" : "Installing";
310 return (380 return (
311 <article381 <article
312 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"382 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({...@@ -327,19 +397,15 @@ export function ThemeSearchSection({
327 </p>397 </p>
328 <div className="mt-auto flex items-center justify-between gap-2">398 <div className="mt-auto flex items-center justify-between gap-2">
329 <div className="flex min-w-0 items-center gap-2">399 <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>
333 {extension.sourceUrl ? (400 {extension.sourceUrl ? (
334 <a401 <Button
335 aria-label={`View source for ${extension.name}`}402 aria-label={`View source for ${extension.name}`}
336 className="inline-flex items-center gap-1 text-muted-foreground text-[11px] hover:text-foreground"403 render={<a href={extension.sourceUrl} rel="noreferrer" target="_blank" />}
337 href={extension.sourceUrl}404 size="icon-micro"
338 rel="noreferrer"405 variant="ghost-muted"
339 target="_blank"
340 >406 >
341 Source <ExternalLinkIcon className="size-3" />407 <SourceLinkIcon url={extension.sourceUrl} />
342 </a>408 </Button>
343 ) : null}409 ) : null}
344 </div>410 </div>
345 <Button411 <Button
...@@ -349,7 +415,13 @@ export function ThemeSearchSection({...@@ -349,7 +415,13 @@ export function ThemeSearchSection({
349 variant="outline"415 variant="outline"
350 onClick={() => void handleInstall(extension, false)}416 onClick={() => void handleInstall(extension, false)}
351 >417 >
352 {isInstalling ? <Spinner /> : isInstalled ? <RefreshCwIcon /> : <PlusIcon />}418 {isInstalling ? (
419 <Spinner />
420 ) : isInstalled ? (
421 <RefreshCwIcon />
422 ) : (
423 <PackagePlusIcon />
424 )}
353 {isInstalling ? `${progressAction}...` : action}425 {isInstalling ? `${progressAction}...` : action}
354 </Button>426 </Button>
355 </div>427 </div>
apps/web/src/components/settings/ThemeSettings.tsx+5-5
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1import {1import {
2 CheckIcon,2 CheckIcon,
3 CopyIcon,3 CopyIcon,
4 DownloadIcon,
5 MoonIcon,4 MoonIcon,
5 PaintbrushIcon,
6 PenLineIcon,6 PenLineIcon,
7 PlusIcon,7 PlusIcon,
8 SunIcon,8 SunIcon,
...@@ -768,7 +768,7 @@ export function ThemeLibrary({...@@ -768,7 +768,7 @@ export function ThemeLibrary({
768 <TooltipProvider>768 <TooltipProvider>
769 <div769 <div
770 className="mx-auto grid w-full max-w-[56rem] gap-2 px-3 sm:px-4"770 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))" }}
772 >772 >
773 {STANDARD_THEME_CARDS.map((standardTheme) => (773 {STANDARD_THEME_CARDS.map((standardTheme) => (
774 <ThemeLibraryCard774 <ThemeLibraryCard
...@@ -870,12 +870,12 @@ export function ThemeLibrary({...@@ -870,12 +870,12 @@ export function ThemeLibrary({
870 })870 })
871 }871 }
872 >872 >
873 <PlusIcon />873 <PaintbrushIcon />
874 Create theme874 Create theme
875 </Button>875 </Button>
876 <Button size="xs" variant="outline" onClick={() => onImportOpenChange(true)}>876 <Button size="xs" variant="outline" onClick={() => onImportOpenChange(true)}>
877 <DownloadIcon />877 <PlusIcon />
878 Import theme878 Add theme
879 </Button>879 </Button>
880 </div>880 </div>
881 </div>881 </div>