feat: add infinite scroll support for extension store (#1055)

* feat: add infinite scroll support for extension store

* docs: update changelog
This commit is contained in:
ayangweb
2026-04-12 14:28:04 +08:00
committed by GitHub
parent 47223b76f7
commit ed2ba8dc76
5 changed files with 178 additions and 47 deletions

View File

@@ -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

View File

@@ -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<SearchExtensionItem[]>([]);
const containerRef = useRef<HTMLDivElement>(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<SearchExtensionItem[]>(
"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<SearchExtensionItem[]>(
"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 (
<div className="h-full text-sm p-4 overflow-auto custom-scrollbar">
<div
ref={containerRef}
className="h-full text-sm p-4 overflow-auto custom-scrollbar"
>
{visibleExtensionDetail ? (
<ExtensionDetail
onInstall={handleInstall}
@@ -317,7 +424,7 @@ const ExtensionStore = ({
{
"bg-black/10 dark:bg-white/15":
selectedExtension?.id === id,
}
},
)}
onMouseOver={() => {
setSelectedExtension(item);
@@ -354,10 +461,27 @@ const ExtensionStore = ({
</div>
);
})
) : (
) : showInitialLoadingState ? (
<div className="flex flex-col justify-center items-center h-full min-h-[50vh] gap-3 text-[#666] dark:text-[#a8a8a8]">
<Loader className="size-5 text-blue-500 animate-spin" />
<span className="text-sm">{t("common.loading")}</span>
</div>
) : !showLoadingState ? (
<div className="flex justify-center items-center h-full">
<SearchEmpty />
</div>
) : null}
{showLoadMoreState && (
<div className="flex justify-center items-center py-4">
<Loader className="size-4 text-blue-500 animate-spin" />
</div>
)}
{showNoMoreState && (
<div className="text-center text-xs text-[#999] py-3">
{t("extensionStore.hints.noMore")}
</div>
)}
</>
)}

View File

@@ -37,11 +37,11 @@ const MultilevelWrapper: FC<MultilevelWrapperProps> = (props) => {
<div
data-tauri-drag-region
className={clsx(
"flex items-center h-10 gap-1 px-2 border border-(--border) rounded-l-lg",
"flex items-center h-10 gap-1 px-2 border border-[#EDEDED] dark:border-[#202126] rounded-l-lg",
{
"justify-center": visibleSearchBar(),
"w-[calc(100vw-16px)] rounded-r-lg": !visibleSearchBar(),
}
},
)}
>
<VisibleKey shortcut="backspace" onKeyPress={navigateBack}>
@@ -121,7 +121,7 @@ export default function SearchIcons({
"flex items-center justify-center bg-[#ededed] dark:bg-[#202126]",
{
"pl-2 h-10": lineCount === 1,
}
},
)}
>
<Search className="w-4 h-4 text-[#ccc] dark:text-[#d8d8d8]" />

View File

@@ -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": {

View File

@@ -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": {