);
}
export function ThemeSearchSection({
open,
onInstalled,
}: {
open: boolean;
onInstalled: (themes: ReadonlyArray, context: { updated: boolean }) => void;
}) {
const [query, setQuery] = useState("");
const [sortBy, setSortBy] = useState("downloadCount");
const [results, setResults] = useState | null>(null);
const [error, setError] = useState(null);
const [isSearching, setIsSearching] = useState(false);
const [installingId, setInstallingId] = useState(null);
const [pendingUpdate, setPendingUpdate] = useState(null);
const requestRef = useRef(null);
// The (query, sort) pair the last search actually ran, so an install
// finishing can tell a same-key rerun (which must not wipe an install
// error) from a query that changed mid-install (which must be searched).
const lastSearchKeyRef = useRef(null);
// The (query, sort) pair from the previous effect run, so a search error
// that belongs to an older key can be cleared when the user returns to
// already-shown results without clearing a fresh install error.
const prevSearchKeyRef = useRef(null);
useEffect(() => {
requestRef.current?.abort();
requestRef.current = null;
if (open) {
lastSearchKeyRef.current = null;
prevSearchKeyRef.current = null;
setQuery("");
setSortBy("downloadCount");
setResults(null);
setError(null);
setIsSearching(false);
setInstallingId(null);
setPendingUpdate(null);
}
return () => {
requestRef.current?.abort();
requestRef.current = null;
};
}, [open]);
const runSearch = useCallback(
async (searchText: string, nextSort = sortBy) => {
const trimmed = searchText.trim();
if (!trimmed) return;
requestRef.current?.abort();
const controller = new AbortController();
requestRef.current = controller;
setError(null);
setIsSearching(true);
try {
const nextResults = await searchOpenVsxThemes(trimmed, {
signal: controller.signal,
sortBy: nextSort,
});
if (!controller.signal.aborted) {
setResults(nextResults);
lastSearchKeyRef.current = `${trimmed}\u0000${nextSort}`;
}
} catch (cause) {
if (!controller.signal.aborted) {
setResults(null);
lastSearchKeyRef.current = null;
setError(cause instanceof Error ? cause.message : "Open VSX search failed.");
}
}
if (requestRef.current === controller) {
requestRef.current = null;
setIsSearching(false);
}
},
[sortBy],
);
const debouncedQuery = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
useEffect(() => {
if (query.trim() || installingId !== null) return;
requestRef.current?.abort();
requestRef.current = null;
lastSearchKeyRef.current = null;
setResults(null);
setError(null);
setIsSearching(false);
}, [query, installingId]);
useEffect(() => {
if (!open) return;
const searchKey = `${debouncedQuery}\u0000${sortBy}`;
const keyChanged = prevSearchKeyRef.current !== searchKey;
prevSearchKeyRef.current = searchKey;
if (installingId !== null) return;
if (!debouncedQuery) {
lastSearchKeyRef.current = null;
requestRef.current?.abort();
requestRef.current = null;
setResults(null);
setError(null);
setIsSearching(false);
return;
}
if (debouncedQuery !== query.trim()) {
// The debounced value still trails the input (dialog reopened with the
// box reset, or the user is mid-keystroke). Searching it would hit Open
// VSX for a query that is no longer visible; wait for the debounce to
// catch up to the current input instead.
return;
}
if (lastSearchKeyRef.current === searchKey) {
// The results already match this query. A request for a newer key may
// still be in flight (typed and then undone); abort it so it cannot
// overwrite the results. Only a genuine key change makes a stale search
// error irrelevant, so an install error on an unchanged query survives.
requestRef.current?.abort();
requestRef.current = null;
setIsSearching(false);
if (keyChanged) setError(null);
return;
}
void runSearch(debouncedQuery);
// `sortBy` is deliberately not a direct dependency: the guards above read
// the current value from the fresh render closure. An install finishing
// reruns the search only when the query or sort changed while it was in
// flight (checked via lastSearchKeyRef, recorded only once a search
// succeeds), so the install error the user needs to see is preserved
// across that rerun.
}, [open, query, debouncedQuery, installingId, runSearch]);
const handleSortChange = useCallback((value: OpenVsxThemeSort | null) => {
const nextSort = SORT_OPTIONS.find((option) => option.value === value)?.value;
if (!nextSort) return;
setSortBy(nextSort);
}, []);
const handleInstall = useCallback(
async (extension: OpenVsxThemeExtension, allowUpdate: boolean) => {
setError(null);
let installedCollection: ReadonlyArray;
try {
installedCollection = getStoredCustomThemeCollection(extension.collectionId);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Installed themes could not be read.");
return;
}
const updated = installedCollection.length > 0;
if (updated && !allowUpdate) {
setPendingUpdate(extension);
return;
}
requestRef.current?.abort();
const controller = new AbortController();
requestRef.current = controller;
setIsSearching(false);
setInstallingId(extension.id);
try {
const themes = await importOpenVsxThemeExtension(extension, controller.signal);
if (!controller.signal.aborted) {
const imported = replaceCustomThemeCollection(extension.collectionId, themes, {
expectedCollection: installedCollection,
});
onInstalled(imported, { updated });
}
} catch (cause) {
if (!controller.signal.aborted) {
setError(cause instanceof Error ? cause.message : "That theme could not be added.");
}
}
if (requestRef.current === controller) {
requestRef.current = null;
setInstallingId(null);
}
},
[onInstalled],
);
return (
)
) : null}
{
if (!nextOpen) setPendingUpdate(null);
}}
>
Update “{pendingUpdate?.name}”?
This replaces its installed variants, including any local edits. Variants no longer in
the extension will be removed.
}>Cancel
);
}