From ed2ba8dc7664a4f3f3d06d23e2c8b766486a1379 Mon Sep 17 00:00:00 2001 From: ayangweb <75017711+ayangweb@users.noreply.github.com> Date: Sun, 12 Apr 2026 14:28:04 +0800 Subject: [PATCH] feat: add infinite scroll support for extension store (#1055) * feat: add infinite scroll support for extension store * docs: update changelog --- docs/content.en/docs/release-notes/_index.md | 3 + src/components/Search/ExtensionStore.tsx | 204 +++++++++++++++---- src/components/Search/SearchIcons.tsx | 6 +- src/locales/en/translation.json | 6 +- src/locales/zh/translation.json | 6 +- 5 files changed, 178 insertions(+), 47 deletions(-) diff --git a/docs/content.en/docs/release-notes/_index.md b/docs/content.en/docs/release-notes/_index.md index 2e6c44ec..b6893090 100644 --- a/docs/content.en/docs/release-notes/_index.md +++ b/docs/content.en/docs/release-notes/_index.md @@ -180,6 +180,9 @@ Information about release notes of Coco App is provided here. ### 🚀 Features +- feat: add infinite scroll support for extension store + #1055 + ### 🐛 Bug fix - fix: correct enter key behavior #828 diff --git a/src/components/Search/ExtensionStore.tsx b/src/components/Search/ExtensionStore.tsx index 9ac7d59a..e95b27bd 100644 --- a/src/components/Search/ExtensionStore.tsx +++ b/src/components/Search/ExtensionStore.tsx @@ -1,5 +1,10 @@ -import { useAsyncEffect, useDebounce, useKeyPress, useUnmount } from "ahooks"; -import { useCallback, useEffect, useState } from "react"; +import { + useDebounce, + useInfiniteScroll, + useKeyPress, + useUnmount, +} from "ahooks"; +import { useCallback, useEffect, useRef } from "react"; import { CircleCheck, FolderDown, Loader } from "lucide-react"; import clsx from "clsx"; import { useTranslation } from "react-i18next"; @@ -13,6 +18,24 @@ import { useShortcutsStore } from "@/stores/shortcutsStore"; import { useAppStore } from "@/stores/appStore"; import { platform } from "@/utils/platform"; +const PAGE_SIZE = 20; + +const filterNewExtensions = ( + currentItems: SearchExtensionItem[], + nextItems: SearchExtensionItem[], +) => { + const seen = new Set(currentItems.map((item) => item.id)); + + return nextItems.filter((item) => { + if (seen.has(item.id)) { + return false; + } + + seen.add(item.id); + return true; + }); +}; + export interface SearchExtensionItem { id: string; created: string; @@ -93,11 +116,15 @@ const ExtensionStore = ({ setVisibleContextMenu, } = useSearchStore(); const debouncedSearchValue = useDebounce(searchValue); - const [list, setList] = useState([]); + const containerRef = useRef(null); + const searchRequestKey = `${extensionId ?? ""}:${debouncedSearchValue.trim()}`; + const currentSearchRequestKeyRef = useRef(searchRequestKey); const { modifierKey } = useShortcutsStore(); const { addError } = useAppStore(); const { t } = useTranslation(); + currentSearchRequestKeyRef.current = searchRequestKey; + useEffect(() => { const unlisten1 = platformAdapter.listenEvent("install-extension", () => { handleInstall(); @@ -119,38 +146,110 @@ const ExtensionStore = ({ "extension_detail", { id: extensionId, - } + }, ); setSelectedExtension(detail); setVisibleExtensionDetail(true); } catch (error) { addError(String(error)); } - }, [extensionId, installingExtensions]); + }, [addError, extensionId, setSelectedExtension, setVisibleExtensionDetail]); - useAsyncEffect(async () => { + const { data, loading, loadingMore, noMore, mutate } = useInfiniteScroll( + async (d) => { + if (extensionId) { + return { + list: [], + hasMore: false, + }; + } + + const requestKey = searchRequestKey; + + const from = d?.list?.length ?? 0; + + const result = await platformAdapter.invokeBackend( + "search_extension", + { + queryParams: parseSearchQuery({ + query: debouncedSearchValue.trim(), + from, + size: PAGE_SIZE, + filters: { + platforms: [platform()], + }, + }), + }, + ); + + if (requestKey !== currentSearchRequestKeyRef.current) { + throw new Error("stale extension search request"); + } + + const currentList = d?.list ?? []; + const nextList = filterNewExtensions(currentList, result ?? []); + + console.log("ExtensionStore nextList", nextList); + + return { + list: nextList, + hasMore: nextList.length === PAGE_SIZE, + }; + }, + { + target: containerRef, + isNoMore: (d) => !d?.hasMore, + reloadDeps: [debouncedSearchValue, extensionId], + onError: (error) => { + if (String(error) === "Error: stale extension search request") { + return; + } + + addError(String(error)); + }, + }, + ); + + const list = data?.list ?? []; + const showLoadingState = !visibleExtensionDetail && (loading || loadingMore); + const showInitialLoadingState = showLoadingState && list.length === 0; + const showLoadMoreState = showLoadingState && list.length > 0; + const showNoMoreState = + !visibleExtensionDetail && !showLoadingState && noMore && list.length > 0; + + useEffect(() => { + mutate(void 0); + + if (!extensionId) { + setSelectedExtension(void 0); + } + }, [extensionId, mutate, searchRequestKey, setSelectedExtension]); + + useEffect(() => { if (extensionId) { - return handleExtensionDetail(); + handleExtensionDetail(); + } + }, [extensionId, handleExtensionDetail]); + + useEffect(() => { + if (extensionId) return; + + if (list.length === 0) { + if (selectedExtension) { + setSelectedExtension(void 0); + } + return; } - const result = await platformAdapter.invokeBackend( - "search_extension", - { - queryParams: parseSearchQuery({ - query: debouncedSearchValue.trim(), - filters: { - platforms: [platform()], - }, - }), - } - ); + const selectedId = selectedExtension?.id; + const hasSelected = selectedId + ? list.some((item) => item.id === selectedId) + : false; - // console.log("search_extension", result); - - setList(result ?? []); - - setSelectedExtension(result?.[0]); - }, [debouncedSearchValue, extensionId]); + if (!hasSelected) { + setSelectedExtension(list[0]); + } + }, [extensionId, list, selectedExtension, setSelectedExtension]); useUnmount(() => { setSelectedExtension(void 0); @@ -167,7 +266,7 @@ const ExtensionStore = ({ setVisibleExtensionDetail(true); }, - { exactMatch: true } + { exactMatch: true }, ); useKeyPress( @@ -179,7 +278,7 @@ const ExtensionStore = ({ handleInstall(); }, - { exactMatch: true } + { exactMatch: true }, ); useKeyPress(["uparrow", "downarrow"], (_, key) => { @@ -206,14 +305,19 @@ const ExtensionStore = ({ const { id, installed } = extension; - setList((prev) => { - return prev.map((item) => { - if (item.id === id) { - return { ...item, installed: !installed }; - } + mutate((prev) => { + if (!prev) return prev; - return item; - }); + return { + ...prev, + list: prev.list.map((item) => { + if (item.id === id) { + return { ...item, installed: !installed }; + } + + return item; + }), + }; }); const { selectedExtension } = useSearchStore.getState(); @@ -247,7 +351,7 @@ const ExtensionStore = ({ addError( `${name} ${t("extensionStore.hints.installationCompleted")}`, - "info" + "info", ); } catch (error) { installExtensionError(error); @@ -255,7 +359,7 @@ const ExtensionStore = ({ const { installingExtensions } = useSearchStore.getState(); setInstallingExtensions( - installingExtensions.filter((item) => item !== id) + installingExtensions.filter((item) => item !== id), ); } }; @@ -282,7 +386,7 @@ const ExtensionStore = ({ addError( `${name} ${t("extensionStore.hints.uninstallationCompleted")}`, - "info" + "info", ); } catch (error) { addError(String(error), "error"); @@ -290,13 +394,16 @@ const ExtensionStore = ({ const { uninstallingExtensions } = useSearchStore.getState(); setUninstallingExtensions( - uninstallingExtensions.filter((item) => item !== id) + uninstallingExtensions.filter((item) => item !== id), ); } }; return ( -
+
{visibleExtensionDetail ? ( { setSelectedExtension(item); @@ -354,10 +461,27 @@ const ExtensionStore = ({
); }) - ) : ( + ) : showInitialLoadingState ? ( +
+ + {t("common.loading")} +
+ ) : !showLoadingState ? (
+ ) : null} + + {showLoadMoreState && ( +
+ +
+ )} + + {showNoMoreState && ( +
+ {t("extensionStore.hints.noMore")} +
)} )} diff --git a/src/components/Search/SearchIcons.tsx b/src/components/Search/SearchIcons.tsx index 28e0773b..30905601 100644 --- a/src/components/Search/SearchIcons.tsx +++ b/src/components/Search/SearchIcons.tsx @@ -37,11 +37,11 @@ const MultilevelWrapper: FC = (props) => {
@@ -121,7 +121,7 @@ export default function SearchIcons({ "flex items-center justify-center bg-[#ededed] dark:bg-[#202126]", { "pl-2 h-10": lineCount === 1, - } + }, )} > diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index afcd86d1..cb1e73d7 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -441,7 +441,8 @@ "title": "{{0}} {{1}}", "placeholder": "Ask More", "continueInChat": "Continue in chat", - "copy": "Copy" + "copy": "Copy", + "emptyResponse": "No response content returned. Please try again." }, "fuzziness": { "fuzzyMatch": "Fuzzy Match" @@ -624,7 +625,8 @@ "extensionStore": { "hints": { "installationCompleted": "installation completed", - "uninstallationCompleted": "uninstallation completed" + "uninstallationCompleted": "uninstallation completed", + "noMore": "No more" } }, "extensionDetail": { diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 52ca988e..83a54c74 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -441,7 +441,8 @@ "title": "{{0}}{{1}}", "placeholder": "问更多", "continueInChat": "继续聊天", - "copy": "复制" + "copy": "复制", + "emptyResponse": "未返回有效内容,请重试。" }, "fuzziness": { "fuzzyMatch": "模糊匹配" @@ -623,7 +624,8 @@ "extensionStore": { "hints": { "installationCompleted": "安装成功", - "uninstallationCompleted": "卸载成功" + "uninstallationCompleted": "卸载成功", + "noMore": "没有更多了" } }, "extensionDetail": {