mirror of
https://github.com/infinilabs/coco-app.git
synced 2026-08-29 01:59:22 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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]" />
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user