Files
coco-app/src/components/Search/ExtensionStore.tsx

362 lines
9.1 KiB
TypeScript
Raw Normal View History

import { useAsyncEffect, useDebounce, useKeyPress, useUnmount } from "ahooks";
import { useCallback, useEffect, useState } from "react";
import { CircleCheck, FolderDown, Loader } from "lucide-react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { useSearchStore } from "@/stores/searchStore";
import { parseSearchQuery } from "@/utils";
import platformAdapter from "@/utils/platformAdapter";
import SearchEmpty from "../Common/SearchEmpty";
import ExtensionDetail from "./ExtensionDetail";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import { useAppStore } from "@/stores/appStore";
import { platform } from "@/utils/platform";
export interface SearchExtensionItem {
id: string;
created: string;
updated: string;
name: string;
description: string;
icon: string;
type: string;
category: string;
tags?: string[];
platforms: string[];
developer: {
id: string;
name: string;
avatar: string;
twitter_handle?: string;
github_handle?: string;
location?: string;
website?: string;
bio?: string;
};
contributors: {
id: string;
name: string;
avatar: string;
}[];
url: {
code: string;
download: string;
};
version: {
number: string;
};
screenshots: {
title?: string;
url: string;
}[];
action: {
exec: string;
args: string[];
};
enabled: boolean;
stats: {
installs: number;
views: number;
};
checksum: string;
installed?: boolean;
commands?: Array<{
type: string;
name: string;
icon: string;
description: string;
action: {
exec: string;
args: string[];
};
}>;
}
const ExtensionStore = ({ extensionId }: { extensionId?: string }) => {
const {
searchValue,
selectedExtension,
setSelectedExtension,
installingExtensions,
setInstallingExtensions,
setUninstallingExtensions,
visibleExtensionDetail,
setVisibleExtensionDetail,
visibleContextMenu,
setVisibleContextMenu,
} = useSearchStore();
const debouncedSearchValue = useDebounce(searchValue);
const [list, setList] = useState<SearchExtensionItem[]>([]);
const { modifierKey } = useShortcutsStore();
const { addError } = useAppStore();
const { t } = useTranslation();
useEffect(() => {
const unlisten1 = platformAdapter.listenEvent("install-extension", () => {
handleInstall();
});
const unlisten2 = platformAdapter.listenEvent("uninstall-extension", () => {
handleUnInstall();
});
return () => {
unlisten1.then((fn) => fn());
unlisten2.then((fn) => fn());
};
}, [selectedExtension]);
const handleExtensionDetail = useCallback(async () => {
try {
const detail = await platformAdapter.invokeBackend<SearchExtensionItem>(
"extension_detail",
{
id: extensionId,
}
);
setSelectedExtension(detail);
setVisibleExtensionDetail(true);
} catch (error) {
addError(String(error));
}
}, [extensionId, installingExtensions]);
useAsyncEffect(async () => {
if (extensionId) {
return handleExtensionDetail();
}
const result = await platformAdapter.invokeBackend<SearchExtensionItem[]>(
"search_extension",
{
queryParams: parseSearchQuery({
query: debouncedSearchValue.trim(),
filters: {
platforms: [platform()],
},
}),
}
);
// console.log("search_extension", result);
setList(result ?? []);
setSelectedExtension(result?.[0]);
}, [debouncedSearchValue, extensionId]);
useUnmount(() => {
setSelectedExtension(void 0);
});
useKeyPress(
"enter",
() => {
if (visibleContextMenu) return;
if (visibleExtensionDetail) {
return handleInstall();
}
setVisibleExtensionDetail(true);
},
{ exactMatch: true }
);
useKeyPress(
`${modifierKey}.enter`,
() => {
if (visibleContextMenu || visibleExtensionDetail) {
return;
}
handleInstall();
},
{ exactMatch: true }
);
useKeyPress(["uparrow", "downarrow"], (_, key) => {
if (visibleContextMenu || visibleExtensionDetail) return;
const index = list.findIndex((item) => item.id === selectedExtension?.id);
const length = list.length;
if (length <= 1) return;
let nextIndex = index;
if (key === "uparrow") {
nextIndex = nextIndex > 0 ? nextIndex - 1 : length - 1;
} else {
nextIndex = nextIndex < length - 1 ? nextIndex + 1 : 0;
}
setSelectedExtension(list[nextIndex]);
});
const toggleInstall = (extension: SearchExtensionItem) => {
if (!extension) return;
const { id, installed } = extension;
setList((prev) => {
return prev.map((item) => {
if (item.id === id) {
return { ...item, installed: !installed };
}
return item;
});
});
const { selectedExtension } = useSearchStore.getState();
if (selectedExtension?.id === id) {
setSelectedExtension({
...selectedExtension,
installed: !installed,
});
}
};
const handleInstall = async () => {
const { selectedExtension, installingExtensions } =
useSearchStore.getState();
if (!selectedExtension) return;
const { id, name, installed } = selectedExtension;
if (installed || installingExtensions.includes(id)) return;
try {
setInstallingExtensions(installingExtensions.concat(id));
await platformAdapter.invokeBackend("install_extension_from_store", {
id,
});
toggleInstall(selectedExtension);
addError(
`${name} ${t("extensionStore.hints.installationCompleted")}`,
"info"
);
} catch (error) {
addError(String(error), "error");
} finally {
const { installingExtensions } = useSearchStore.getState();
setInstallingExtensions(
installingExtensions.filter((item) => item !== id)
);
}
};
const handleUnInstall = async () => {
const { selectedExtension, uninstallingExtensions } =
useSearchStore.getState();
if (!selectedExtension) return;
const { id, name, installed, developer } = selectedExtension;
if (!installed || uninstallingExtensions.includes(id)) return;
try {
setUninstallingExtensions(uninstallingExtensions.concat(id));
await platformAdapter.invokeBackend("uninstall_extension", {
developer: developer.id,
extensionId: id,
});
toggleInstall(selectedExtension);
addError(
`${name} ${t("extensionStore.hints.uninstallationCompleted")}`,
"info"
);
} catch (error) {
addError(String(error), "error");
} finally {
const { uninstallingExtensions } = useSearchStore.getState();
setUninstallingExtensions(
uninstallingExtensions.filter((item) => item !== id)
);
}
};
return (
<div className="h-full text-sm p-4 overflow-auto custom-scrollbar">
{visibleExtensionDetail ? (
<ExtensionDetail
onInstall={handleInstall}
onUninstall={handleUnInstall}
/>
) : (
<>
{list.length > 0 ? (
list.map((item) => {
const { id, icon, name, description, stats, installed } = item;
return (
<div
key={id}
className={clsx(
"flex justify-between gap-4 h-[40px] px-2 rounded-lg cursor-pointer text-[#333] dark:text-[#d8d8d8] transition",
{
"bg-black/10 dark:bg-white/15":
selectedExtension?.id === id,
}
)}
onMouseOver={() => {
setSelectedExtension(item);
}}
onClick={() => {
setVisibleExtensionDetail(true);
}}
onContextMenu={(event) => {
event.preventDefault();
setVisibleContextMenu(true);
}}
>
<div className="flex items-center gap-2 overflow-hidden">
<img src={icon} className="size-[20px]" />
<span className="whitespace-nowrap">{name}</span>
<span className="truncate text-[#999]">{description}</span>
</div>
<div className="flex items-center gap-3">
{installed && (
<CircleCheck className="size-4 text-green-500" />
)}
{installingExtensions.includes(item.id) && (
<Loader className="size-4 text-blue-500 animate-spin" />
)}
<div className="flex items-center gap-1 text-[#999]">
<FolderDown className="size-4" />
<span>{stats.installs}</span>
</div>
</div>
</div>
);
})
) : (
<div className="flex justify-center items-center h-full">
<SearchEmpty />
</div>
)}
</>
)}
</div>
);
};
export default ExtensionStore;