feat: impl extension store (#699)

Implements extension store so that users can install extensions from a GUI interface


---------

Co-authored-by: ayang <473033518@qq.com>
This commit is contained in:
SteveLauC
2025-06-26 18:40:33 +08:00
committed by GitHub
parent 69d2b4b834
commit b3f68697ce
44 changed files with 2196 additions and 262 deletions

View File

@@ -0,0 +1,119 @@
import {
Button,
ButtonProps,
Description,
Dialog,
DialogPanel,
DialogTitle,
} from "@headlessui/react";
import { FC, KeyboardEvent } from "react";
import clsx from "clsx";
import { twMerge } from "tailwind-merge";
import VisibleKey from "./VisibleKey";
import { useTranslation } from "react-i18next";
interface DeleteDialogProps {
isOpen: boolean;
title: string;
description: string;
deleteButtonProps?: ButtonProps;
cancelButtonProps?: ButtonProps;
reverseButtonPosition?: boolean;
setIsOpen: (isOpen: boolean) => void;
onCancel: () => void;
onDelete: () => void;
}
const DeleteDialog: FC<DeleteDialogProps> = (props) => {
const {
isOpen,
setIsOpen,
title,
description,
deleteButtonProps,
cancelButtonProps,
reverseButtonPosition,
onCancel,
onDelete,
} = props;
const { t } = useTranslation();
const handleEnter = (event: KeyboardEvent, fn: () => void) => {
if (event.code !== "Enter") return;
event.stopPropagation();
event.preventDefault();
fn();
};
return (
<Dialog
open={isOpen}
onClose={() => setIsOpen(false)}
className="relative z-1000"
>
<div
id="headlessui-popover-panel:delete-history"
className="fixed inset-0 flex items-center justify-center w-screen"
>
<DialogPanel className="flex flex-col justify-between w-[360px] h-[160px] p-3 text-[#333] dark:text-white/90 border border-[#e6e6e6] bg-white dark:bg-[#202126] dark:border-white/10 shadow-[0_4px_10px_rgba(0,0,0,0.2)] rounded-lg dark:shadow-[0_8px_20px_rgba(0,0,0,0.4)]">
<div className="flex flex-col gap-3">
<DialogTitle className="text-base font-bold">{title}</DialogTitle>
<Description className="text-sm">{description}</Description>
</div>
<div
className={clsx("flex gap-4 self-end", {
"flex-row-reverse": reverseButtonPosition,
})}
>
<VisibleKey
shortcut="N"
shortcutClassName="left-[unset] right-0"
onKeyPress={onCancel}
>
<Button
{...cancelButtonProps}
autoFocus
className={twMerge(
"h-8 px-4 text-sm text-[#666666] bg-[#F8F9FA] dark:text-white dark:bg-[#202126] border border-[#E6E6E6] dark:border-white/10 rounded-lg focus:border-black/30 dark:focus:border-white/50 transition",
cancelButtonProps?.className as string
)}
onClick={onCancel}
onKeyDown={(event) => {
handleEnter(event, onCancel);
}}
>
{t("deleteDialog.button.cancel")}
</Button>
</VisibleKey>
<VisibleKey
shortcut="Y"
shortcutClassName="left-[unset] right-0"
onKeyPress={onDelete}
>
<Button
{...deleteButtonProps}
className={twMerge(
"h-8 px-4 text-sm text-white bg-[#EF4444] rounded-lg border border-[#EF4444] focus:border-black/30 dark:focus:border-white/50 transition",
deleteButtonProps?.className as string
)}
onClick={onDelete}
onKeyDown={(event) => {
handleEnter(event, onDelete);
}}
>
{t("deleteDialog.button.delete")}
</Button>
</VisibleKey>
</div>
</DialogPanel>
</div>
</Dialog>
);
};
export default DeleteDialog;

View File

@@ -0,0 +1,118 @@
import { useThemeStore } from "@/stores/themeStore";
import { FC } from "react";
import { useTranslation } from "react-i18next";
interface SearchEmptyProps {
width?: number;
height?: number;
}
const SearchEmpty: FC<SearchEmptyProps> = (props) => {
const { width = 108, height } = props;
const { isDark } = useThemeStore();
const { t } = useTranslation();
return (
<div className="flex flex-col items-center gap-4">
<svg
width={width}
height={height}
viewBox="0 0 110 74"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<title> 7</title>
<g
id="插件商店"
stroke="none"
strokeWidth="1"
fill="none"
fillRule="evenodd"
>
<g
id="无结果"
transform="translate(-285, -238)"
stroke={isDark ? "#666" : "#999"}
strokeWidth="2"
>
<g id="编组-7" transform="translate(286.0008, 239)">
<path
d="M13.3231659,21.5136996 C13.3231659,19.3007352 13.3231659,14.8686653 13.3231659,8.21749008 C13.3231659,3.67909563 17.0122442,0 21.5629529,0 L88.2118384,0 C92.7625471,0 96.4516254,3.67909563 96.4516254,8.21749008 C96.4516254,10.094192 96.4516254,11.5017184 96.4516254,12.4400693 M96.4516254,51.9326386 C96.4516254,53.9261881 96.4516254,57.8761452 96.4516254,63.7825099 C96.4516254,68.3209044 92.7625471,72 88.2118384,72 L21.5629529,72 C17.0122442,72 13.3231659,68.3209044 13.3231659,63.7825099 L13.3231659,60.938714"
id="形状"
strokeDasharray="7,3"
></path>
<ellipse
id="椭圆形备份"
cx="81.1877607"
cy="29.2037781"
rx="18.4438929"
ry="18.182295"
></ellipse>
<line
x1="94.7817074"
y1="42.614832"
x2="108"
y2="55.6552859"
id="路径-4备份"
strokeLinecap="round"
></line>
<path
d="M10.5844571,27.5074364 C16.6773969,25.9924085 23.1773619,29.6710245 27.386048,36.2620174 C22.1657703,35.1830338 16.8575124,35.21291 11.6484221,36.5081661 C7.41338948,37.5612222 3.51420993,39.3834599 -0.000291581531,41.8525859 C0.923937746,34.6468262 4.82125546,28.9404773 10.5844571,27.5074364 Z"
id="形状结合备份-7"
strokeLinejoin="round"
></path>
<path
d="M37.8969953,26.1959104 C43.5101629,25.7114352 48.6739265,29.2024386 51.2337447,34.5954621 C47.1674647,33.4803904 42.8983353,33.0573353 38.538847,33.4336049 C34.1798035,33.8098457 30.0503934,34.9576309 26.2420368,36.7514978 C27.813275,31.0027019 32.2840091,26.6803824 37.8969953,26.1959104 Z"
id="形状结合备份-8"
strokeLinejoin="round"
></path>
<path
d="M31.539458,18.2001402 C36.5615365,15.6541096 42.6600468,16.9613729 47.0591458,21.0046327 C42.8699689,21.4911269 38.7533829,22.6937278 34.8532022,24.6709927 C30.9523581,26.6486264 27.5543743,29.2560064 24.6969024,32.3425899 C23.9951662,26.4249906 26.5167961,20.7465085 31.539458,18.2001402 Z"
id="形状结合备份-11"
strokeLinejoin="round"
transform="translate(35.821, 24.6183) rotate(-12) translate(-35.821, -24.6183)"
></path>
<path
d="M10.5436753,41.4266578 C14.7331796,36.1358502 21.2661914,34.5295217 27.1728604,36.6822627 C23.0525507,39.325329 19.2570765,42.737484 15.9487291,46.9155032 C12.640202,51.0937495 10.0569324,55.7372814 8.18485826,60.662697 C5.65340555,54.2465445 6.3541482,46.7174943 10.5436753,41.4266578 Z"
id="形状结合备份-9"
strokeLinejoin="round"
></path>
<path
d="M26.9124079,37.8021241 C31.7762268,33.9875103 38.3818206,33.4524199 44.0035888,37.0544707 C49.6980215,40.7030801 52.8264479,47.5991177 52.5343362,54.4887727 C49.1502233,50.435903 45.1867531,46.8795531 40.6898778,43.9982579 C36.1927406,41.116795 31.4857464,39.1178133 26.7239231,37.952533 Z"
id="形状结合备份-10"
strokeLinejoin="round"
></path>
<path
d="M25.2800001,38.5113791 C28.9578107,48.5878922 28.4367035,59.4047936 23.7166785,70.9620834"
id="路径-7备份-2"
></path>
<path
d="M29.8677805,38.5132245 C35.0745191,48.0589279 36.9874556,58.8758293 35.60659,70.9639287"
id="路径-7备份-3"
></path>
<line
x1="28.2081316"
y1="51.976707"
x2="30.2418038"
y2="51.976707"
id="路径-2"
></line>
<line
x1="28.2081316"
y1="57.0471296"
x2="31.2586399"
y2="57.0471296"
id="路径-2备份"
></line>
</g>
</g>
</g>
</svg>
<span className="text-sm text-[#999]">{t("search.main.noResults")}</span>
</div>
);
};
export default SearchEmpty;

View File

@@ -18,6 +18,7 @@ import source_default_img from "@/assets/images/source_default.png";
import source_default_dark_img from "@/assets/images/source_default_dark.png";
import { useThemeStore } from "@/stores/themeStore";
import platformAdapter from "@/utils/platformAdapter";
import FontIcon from "../Icons/FontIcon";
interface FooterProps {
setIsPinnedWeb?: (value: boolean) => void;
@@ -26,7 +27,13 @@ interface FooterProps {
export default function Footer({ setIsPinnedWeb }: FooterProps) {
const { t } = useTranslation();
const { sourceData, goAskAi } = useSearchStore();
const {
sourceData,
goAskAi,
selectedExtension,
visibleExtensionStore,
visibleExtensionDetail,
} = useSearchStore();
const isDark = useThemeStore((state) => state.isDark);
@@ -56,6 +63,63 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
return platformAdapter.emitEvent("open_settings", "");
}, []);
const renderLeft = () => {
if (sourceData?.source?.name) {
return (
<CommonIcon
item={sourceData}
renderOrder={["connector_icon", "default_icon"]}
itemIcon={sourceData?.source?.icon}
defaultIcon={isDark ? source_default_dark_img : source_default_img}
className="w-4 h-4"
/>
);
}
if (visibleExtensionDetail && selectedExtension) {
return (
<div className="flex items-center gap-2">
<img src={selectedExtension.icon} />
<span className="text-sm">{selectedExtension.name}</span>
</div>
);
}
if (visibleExtensionStore) {
return (
<div className="flex items-center gap-2">
<FontIcon name="font_Store" className="size-5" />
<span className="text-sm">Extension Store</span>
</div>
);
}
return (
<>
<img
src={logoImg}
className="w-4 h-4 cursor-pointer"
onClick={openSetting}
alt={t("search.footer.logoAlt")}
/>
<div className="relative text-xs text-gray-500 dark:text-gray-400">
{updateInfo?.available ? (
<div className="cursor-pointer" onClick={() => setVisible(true)}>
<span>{t("search.footer.updateAvailable")}</span>
<span className="absolute top-0 -right-2 size-1.5 bg-[#FF3434] rounded-full"></span>
</div>
) : (
sourceData?.source?.name ||
t("search.footer.version", {
version: process.env.VERSION || "v1.0.0",
})
)}
</div>
</>
);
};
return (
<div
data-tauri-drag-region={isTauri}
@@ -64,40 +128,7 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
{isTauri ? (
<div className="flex items-center">
<div className="flex items-center space-x-2">
{sourceData?.source?.name ? (
<CommonIcon
item={sourceData}
renderOrder={["connector_icon", "default_icon"]}
itemIcon={sourceData?.source?.icon}
defaultIcon={
isDark ? source_default_dark_img : source_default_img
}
className="w-4 h-4"
/>
) : (
<img
src={logoImg}
className="w-4 h-4 cursor-pointer"
onClick={openSetting}
alt={t("search.footer.logoAlt")}
/>
)}
<div className="relative text-xs text-gray-500 dark:text-gray-400">
{updateInfo?.available ? (
<div
className="cursor-pointer"
onClick={() => setVisible(true)}
>
<span>{t("search.footer.updateAvailable")}</span>
<span className="absolute top-0 -right-2 size-1.5 bg-[#FF3434] rounded-full"></span>
</div>
) : (
sourceData?.source?.name ||
t("search.footer.version", {
version: process.env.VERSION || "v1.0.0",
})
)}
</div>
{renderLeft()}
<button
onClick={togglePin}
@@ -117,12 +148,20 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
)}
<div className={`flex mobile:hidden items-center gap-3`}>
<div className="gap-1 flex items-center text-[#666] dark:text-[#666] text-xs">
<div
className={clsx(
"gap-1 flex items-center text-[#666] dark:text-[#666] text-xs",
{
hidden: visibleExtensionDetail || selectedExtension?.installed,
}
)}
>
<span className="mr-1.5">
{goAskAi
? t("search.askCocoAi.continueInChat")
: !selectedExtension?.installed
? t("search.footer.install")
: t("search.footer.select")}
:
</span>
<kbd className="coco-modal-footer-commands-key pr-1">
<div className="flex items-center justify-center min-w-3 h-3">
@@ -131,16 +170,30 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
</kbd>
+
<kbd className="coco-modal-footer-commands-key pr-1">
{goAskAi ? (
{goAskAi || selectedExtension ? (
<CornerDownLeft className="w-3 h-3" />
) : (
<ArrowDown01 className="w-3 h-3" />
)}
</kbd>
</div>
<div className="flex items-center text-[#666] dark:text-[#666] text-xs">
<div
className={clsx(
"flex items-center text-[#666] dark:text-[#666] text-xs",
{
hidden: visibleExtensionDetail && selectedExtension?.installed,
}
)}
>
<span className="mr-1.5">
{goAskAi ? t("search.askCocoAi.copy") : t("search.footer.open")}:{" "}
{goAskAi
? t("search.askCocoAi.copy")
: visibleExtensionDetail && !selectedExtension?.installed
? t("search.footer.install")
: visibleExtensionStore
? t("search.footer.details")
: t("search.footer.open")}
</span>
<kbd className="coco-modal-footer-commands-key pr-1">
<CornerDownLeft className="w-3 h-3" />

View File

@@ -2,9 +2,9 @@ import { useTranslation } from "react-i18next";
import { isMac } from "@/utils/platform";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import noDataImg from "@/assets/coconut-tree.png";
import clsx from "clsx";
import { formatKey } from "@/utils/keyboardUtils";
import SearchEmpty from "../SearchEmpty";
export const NoResults = () => {
const { t } = useTranslation();
@@ -17,10 +17,8 @@ export const NoResults = () => {
data-tauri-drag-region
className="h-full w-full flex flex-col justify-center items-center"
>
<img src={noDataImg} alt="no-data" className="w-16 h-16" />
<div className="mt-4 text-sm text-[#999] dark:text-[#666]">
{t("search.main.noResults")}
</div>
<SearchEmpty />
<div
className={`flex mobile:hidden mt-10 text-sm text-[#333] dark:text-[#D8D8D8]`}
>

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef, useMemo, useState } from "react";
import { useCallback, useRef, useMemo, useState, useEffect } from "react";
import { cloneDeep, isEmpty } from "lodash-es";
import { useSearchStore } from "@/stores/searchStore";
@@ -23,8 +23,17 @@ export function useAssistantManager({
}: AssistantManagerProps) {
const isTauri = useAppStore((state) => state.isTauri);
const { goAskAi, setGoAskAi, setAskAiMessage, selectedAssistant } =
useSearchStore();
const {
goAskAi,
setGoAskAi,
setAskAiMessage,
selectedAssistant,
selectedSearchContent,
visibleExtensionStore,
setVisibleExtensionStore,
setSearchValue,
visibleExtensionDetail,
} = useSearchStore();
const { quickAiAccessAssistant, disabledExtensions } = useExtensionsStore();
@@ -78,29 +87,63 @@ export function useAssistantManager({
setGoAskAi(true);
}, [disabledExtensions, askAI, inputValue, goAskAi, selectedAssistant]);
const handleKeyDownAutoResizeTextarea = useCallback((
e: React.KeyboardEvent<HTMLTextAreaElement>
) => {
const { key, shiftKey, currentTarget } = e;
const { value } = currentTarget;
const handleKeyDownAutoResizeTextarea = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const { key, shiftKey, currentTarget } = e;
const { value } = currentTarget;
if (key === "Backspace" && value === "") {
return setGoAskAi(false);
}
if (key === "Backspace" && value === "") {
return setGoAskAi(false);
}
if (key === "Tab" && !isChatMode && isTauri) {
e.preventDefault();
if (key === "Tab" && !isChatMode && isTauri) {
e.preventDefault();
assistant_get();
return handleAskAi();
}
if (visibleExtensionStore) return;
if (key === "Enter" && !shiftKey && !isChatMode && isTauri) {
e.preventDefault();
if (selectedSearchContent?.id === "extension_store") {
changeInput("");
setSearchValue("");
return setVisibleExtensionStore(true);
}
goAskAi ? handleAskAi() : handleSubmit();
}
}, [isChatMode, goAskAi, assistant_get, handleAskAi, handleSubmit]);
assistant_get();
return handleAskAi();
}
if (key === "Enter" && !shiftKey && !isChatMode && isTauri) {
e.preventDefault();
goAskAi ? handleAskAi() : handleSubmit();
}
},
[
isChatMode,
goAskAi,
assistant_get,
handleAskAi,
handleSubmit,
selectedSearchContent,
]
);
useEffect(() => {
const unlisten = platformAdapter.listenEvent("open-extension-store", () => {
platformAdapter.showWindow();
if (visibleExtensionStore || visibleExtensionDetail) return;
changeInput("");
setSearchValue("");
setVisibleExtensionStore(true);
});
return () => {
unlisten.then((fn) => {
fn();
});
};
}, [visibleExtensionStore, visibleExtensionDetail]);
return {
askAI,

View File

@@ -1,8 +1,16 @@
import { useClickAway, useCreation, useReactive } from "ahooks";
import clsx from "clsx";
import { isNil, lowerCase, noop } from "lodash-es";
import { Copy, Link, SquareArrowOutUpRight } from "lucide-react";
import { cloneElement, FC, useEffect, useRef, useState } from "react";
import {
Copy,
Download,
Info,
Link,
Settings,
SquareArrowOutUpRight,
Trash2,
} from "lucide-react";
import { cloneElement, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Input } from "@headlessui/react";
@@ -19,9 +27,7 @@ interface State {
activeMenuIndex: number;
}
interface ContextMenuProps {}
const ContextMenu: FC<ContextMenuProps> = () => {
const ContextMenu = () => {
const containerRef = useRef<HTMLDivElement>(null);
const { t, i18n } = useTranslation();
const state = useReactive<State>({
@@ -39,28 +45,77 @@ const ContextMenu: FC<ContextMenuProps> = () => {
});
const [searchMenus, setSearchMenus] = useState<typeof menus>([]);
const searchInputRef = useRef<HTMLInputElement>(null);
const { selectedExtension, setVisibleExtensionDetail } = useSearchStore();
const title = useCreation(() => {
if (selectedExtension) {
return selectedExtension.name;
}
if (selectedSearchContent?.id === "Calculator") {
return t("search.contextMenu.title.calculator");
}
return selectedSearchContent?.title;
}, [selectedSearchContent]);
}, [selectedSearchContent, selectedExtension]);
const menus = useCreation(() => {
if (isNil(selectedSearchContent)) return [];
const { url, category, type, payload } = selectedSearchContent;
const { query, result } = payload ?? {};
if (category === "AI Overview") {
setSearchMenus([]);
if (selectedExtension) {
return [
{
name: t("search.contextMenu.details"),
icon: <Info />,
keys: isMac ? ["↩︎"] : ["Enter"],
shortcut: "enter",
clickEvent() {
setVisibleExtensionDetail(true);
},
},
{
name: t("search.contextMenu.install"),
icon: <Download />,
keys: isMac ? ["⌘", "↩︎"] : ["Ctrl", "Enter"],
shortcut: isMac ? "meta.enter" : "ctrl.enter",
hide: selectedExtension.installed,
clickEvent() {
platformAdapter.emitEvent("install-extension");
},
},
{
name: t("search.contextMenu.configureExtension"),
icon: <Settings />,
keys: isMac ? ["⌘", "/"] : ["Ctrl", "/"],
shortcut: isMac ? "meta.forwardslash" : "ctrl.forwardslash",
hide: !selectedExtension.installed,
clickEvent() {
platformAdapter.emitEvent("config-extension", selectedExtension.id);
},
},
{
name: t("search.contextMenu.uninstall"),
icon: <Trash2 />,
keys: isMac ? ["⌘", "X"] : ["Ctrl", "X"],
shortcut: isMac ? "meta.x" : "ctrl.x",
hide: !selectedExtension.installed,
clickEvent() {
platformAdapter.emitEvent("uninstall-extension");
},
},
];
}
if (isNil(selectedSearchContent)) {
return [];
}
const menus = [
const { id, url, category, type, payload } = selectedSearchContent;
const { query, result } = payload ?? {};
if (category === "AI Overview") {
return [];
}
return [
{
name: t("search.contextMenu.open"),
icon: <SquareArrowOutUpRight />,
@@ -76,7 +131,10 @@ const ContextMenu: FC<ContextMenuProps> = () => {
icon: <Link />,
keys: isMac ? ["⌘", "L"] : ["Ctrl", "L"],
shortcut: isMac ? "meta.l" : "ctrl.l",
hide: category === "Calculator" || type === "AI Assistant",
hide:
category === "Calculator" ||
type === "AI Assistant" ||
id === "extension_store",
clickEvent() {
copyToClipboard(url);
},
@@ -95,7 +153,7 @@ const ContextMenu: FC<ContextMenuProps> = () => {
name: t("search.contextMenu.copyUppercaseAnswer"),
icon: <Copy />,
keys: isMac ? ["⌘", "↩︎"] : ["Ctrl", "Enter"],
shortcut: "meta.enter",
shortcut: isMac ? "meta.enter" : "ctrl.enter",
hide: category !== "Calculator",
clickEvent() {
copyToClipboard(i18n.language === "zh" ? result.toZh : result.toEn);
@@ -105,24 +163,24 @@ const ContextMenu: FC<ContextMenuProps> = () => {
name: t("search.contextMenu.copyQuestionAndAnswer"),
icon: <Copy />,
keys: isMac ? ["⌘", "L"] : ["Ctrl", "L"],
shortcut: "meta.l",
shortcut: isMac ? "meta.l" : "ctrl+l",
hide: category !== "Calculator",
clickEvent() {
copyToClipboard(`${query.value} = ${result.value}`);
},
},
];
}, [selectedSearchContent, selectedExtension]);
const filterMenus = menus.filter((item) => !item.hide);
useEffect(() => {
const filterMenus = menus.filter((item) => !item?.hide);
setSearchMenus(filterMenus);
return filterMenus;
}, [selectedSearchContent]);
}, [menus]);
const shortcuts = useCreation(() => {
return menus.map((item) => item.shortcut);
}, [menus]);
return searchMenus.map((item) => item.shortcut);
}, [searchMenus]);
useEffect(() => {
state.activeMenuIndex = 0;
@@ -134,8 +192,8 @@ const ContextMenu: FC<ContextMenuProps> = () => {
}
}, [selectedSearchContent]);
useOSKeyPress(["meta.k", "ctrl.k"], () => {
if (isNil(selectedSearchContent)) return;
useOSKeyPress(["meta.k", "ctrl+k"], () => {
if (isNil(selectedSearchContent) && isNil(selectedExtension)) return;
setVisibleContextMenu(!visibleContextMenu);
});
@@ -148,7 +206,7 @@ const ContextMenu: FC<ContextMenuProps> = () => {
if (!visibleContextMenu) return;
const index = state.activeMenuIndex;
const length = menus.length;
const length = searchMenus.length;
switch (key) {
case "uparrow":
@@ -166,9 +224,9 @@ const ContextMenu: FC<ContextMenuProps> = () => {
let matched;
if (key === "enter") {
matched = menus.find((_, index) => index === state.activeMenuIndex);
matched = searchMenus.find((_, index) => index === state.activeMenuIndex);
} else {
matched = menus.find((item) => item.shortcut === key);
matched = searchMenus.find((item) => item.shortcut === key);
}
handleClick(matched?.clickEvent);
@@ -181,7 +239,9 @@ const ContextMenu: FC<ContextMenuProps> = () => {
const handleClick = (click = noop) => {
click?.();
setVisibleContextMenu(false);
requestAnimationFrame(() => {
setVisibleContextMenu(false);
});
};
return (
@@ -272,11 +332,11 @@ const ContextMenu: FC<ContextMenuProps> = () => {
onChange={(event) => {
const value = specialCharacterFiltering(event.target.value);
const searchMenus = menus.filter((item) => {
return lowerCase(item.name).includes(lowerCase(value));
setSearchMenus((prev) => {
return prev.filter((item) => {
return lowerCase(item.name).includes(lowerCase(value));
});
});
setSearchMenus(searchMenus);
}}
/>
</VisibleKey>

View File

@@ -4,13 +4,13 @@ import { useTranslation } from "react-i18next";
import { useSearchStore } from "@/stores/searchStore";
import { SearchHeader } from "./SearchHeader";
import noDataImg from "@/assets/coconut-tree.png";
import { metaOrCtrlKey } from "@/utils/keyboardUtils";
import SearchListItem from "./SearchListItem";
import platformAdapter from "@/utils/platformAdapter";
import { Get } from "@/api/axiosRequest";
import { useAppStore } from "@/stores/appStore";
import { useConnectStore } from "@/stores/connectStore";
import SearchEmpty from "../Common/SearchEmpty";
interface DocumentListProps {
onSelectDocument: (id: string) => void;
@@ -255,16 +255,9 @@ export const DocumentList: React.FC<DocumentListProps> = ({
{!loading && (!data?.list || data.list.length === 0) && (
<div
data-tauri-drag-region
className="h-full w-full flex flex-col items-center"
className="h-full w-full flex flex-col justify-center items-center"
>
<img
src={noDataImg}
alt={t("search.list.noDataAlt")}
className="w-16 h-16 mt-24"
/>
<div className="mt-4 text-sm text-[#999] dark:text-[#666]">
{t("search.list.noResults")}
</div>
<SearchEmpty />
</div>
)}
</div>

View File

@@ -0,0 +1,175 @@
import { useSearchStore } from "@/stores/searchStore";
import { Button } from "@headlessui/react";
import dayjs from "dayjs";
import {
CircleCheck,
Download,
FolderDown,
GitFork,
Loader,
Trash2,
User,
} from "lucide-react";
import { FC, useState } from "react";
import DeleteDialog from "../Common/DeleteDialog";
import { useTranslation } from "react-i18next";
interface ExtensionDetailProps {
onInstall: () => void;
onUninstall: () => void;
}
const ExtensionDetail: FC<ExtensionDetailProps> = (props) => {
const { onInstall, onUninstall } = props;
const { selectedExtension, installingExtensions } = useSearchStore();
const [isOpen, setIsOpen] = useState(false);
const { t } = useTranslation();
const handleCancel = () => {
setIsOpen(false);
};
const handleDelete = () => {
onUninstall();
setIsOpen(false);
};
return (
selectedExtension && (
<>
<div className="text-sm text-[#333] dark:text-white">
<div className="flex justify-between">
<div className="flex gap-4">
<img src={selectedExtension.icon} className="size-[56px]" />
<div className="flex flex-col justify-between">
<span>{selectedExtension.name}</span>
<div className="flex items-center gap-6 text-[#999]">
<div className="flex items-center gap-1">
<User className="size-4" />
<span>{selectedExtension.developer.name}</span>
</div>
<div className="flex items-center gap-1">
<GitFork className="size-4" />
<span>v{selectedExtension.version.number}</span>
</div>
<div className="flex items-center gap-1">
<FolderDown className="size-4" />
<span>{selectedExtension.stats.installs}</span>
</div>
</div>
</div>
</div>
<div className="pt-2">
{selectedExtension.installed ? (
<div className="flex items-center gap-2">
<Trash2
className="size-4 text-red-500 cursor-pointer"
onClick={() => {
setIsOpen(true);
}}
/>
<div className="flex items-center gap-1 h-6 px-2 rounded-full text-[#22C461] bg-[#22C461]/20">
<CircleCheck className="size-4" />
<span>{t("extensionDetail.hints.installed")}</span>
</div>
</div>
) : (
<Button
className="flex justify-center items-center w-14 h-6 rounded-full bg-[#007BFF] text-white"
onClick={() => {
onInstall();
}}
>
{installingExtensions.includes(selectedExtension.id) ? (
<Loader className="size-4 animate-spin" />
) : (
<Download className="size-4" />
)}
</Button>
)}
</div>
</div>
{(selectedExtension.screenshots?.length ?? 0) > 0 && (
<div className="flex gap-3 py-4 border-b dark:border-b-[#262626]">
{selectedExtension.screenshots.map((item) => {
return (
<img key={item.url} src={item.url} className="h-[125px]" />
);
})}
</div>
)}
<div className="mb-1 mt-4">
{t("extensionDetail.label.description")}
</div>
<div className="mb-4 text-[#999]">
{selectedExtension.description}
</div>
{(selectedExtension.commands?.length ?? 0) > 0 && (
<>
<div className="mb-1">{t("extensionDetail.label.commands")}</div>
{selectedExtension.commands?.map((item) => {
return (
<div key={item.name}>
<div className="mb-1">{item.name}</div>
<div className="mb-4 text-[#999]">{item.description}</div>
</div>
);
})}
</>
)}
{(selectedExtension.tags?.length ?? 0) > 0 && (
<>
<div className="mb-1">{t("extensionDetail.label.tags")}</div>
<div className="mb-4">
{selectedExtension.tags?.map((item) => {
return (
<div
key={item}
className="h-6 px-2 rounded text-[#999] bg-[#E6E6E6] dark:bg-[#333]"
>
{item}
</div>
);
})}
</div>
</>
)}
<span className="mb-1">{t("extensionDetail.label.lastUpdate")}</span>
<div className="text-[#999]">
{dayjs(selectedExtension.updated).format("YYYY-MM-DD HH:mm:ss")}
</div>
</div>
<DeleteDialog
reverseButtonPosition
isOpen={isOpen}
title={`${t("extensionDetail.deleteDialog.title")} ${
selectedExtension.name
}`}
description={t("extensionDetail.deleteDialog.description")}
cancelButtonProps={{
className:
"text-white bg-[#007BFF] border-[#007BFF] dark:bg-[#007BFF] dark:border-[#007BFF]",
}}
deleteButtonProps={{
className:
"!text-[#FF4949] bg-[#F8F9FA] dark:text-white dark:bg-[#202126] border-[#E6E6E6] dark:border-white/10",
}}
setIsOpen={setIsOpen}
onCancel={handleCancel}
onDelete={handleDelete}
/>
</>
)
);
};
export default ExtensionDetail;

View File

@@ -0,0 +1,338 @@
import { useSearchStore } from "@/stores/searchStore";
import { parseSearchQuery } from "@/utils";
import platformAdapter from "@/utils/platformAdapter";
import { useAsyncEffect, useDebounce, useKeyPress, useUnmount } from "ahooks";
import SearchEmpty from "../Common/SearchEmpty";
import { useEffect, useState } from "react";
import { CircleCheck, FolderDown, Loader } from "lucide-react";
import clsx from "clsx";
import ExtensionDetail from "./ExtensionDetail";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import { useAppStore } from "@/stores/appStore";
import { platform } from "@/utils/platform";
import { useTranslation } from "react-i18next";
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 = () => {
const {
searchValue,
selectedExtension,
setSelectedExtension,
installingExtensions,
setInstallingExtensions,
uninstallingExtensions,
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]);
useAsyncEffect(async () => {
if (!debouncedSearchValue.trim()) {
return setList([]);
}
const result = await platformAdapter.invokeBackend<SearchExtensionItem[]>(
"search_extension",
{
queryParams: parseSearchQuery({
query: debouncedSearchValue,
filters: {
platforms: [platform()],
},
}),
}
);
console.log("search_extension", result);
setList(result ?? []);
setSelectedExtension(result?.[0]);
}, [debouncedSearchValue]);
useUnmount(() => {
setSelectedExtension(void 0);
});
useKeyPress(
"enter",
() => {
if (visibleContextMenu) return;
if (visibleExtensionDetail) {
return handleInstall();
}
setVisibleExtensionDetail(true);
},
{ exactMatch: true }
);
useKeyPress(
`${modifierKey}.enter`,
() => {
if (
visibleContextMenu ||
visibleExtensionDetail ||
selectedExtension?.installed
) {
return;
}
handleInstall();
},
{ exactMatch: true }
);
useKeyPress(["uparrow", "downarrow"], (_, key) => {
console.log("visibleContextMenu", visibleContextMenu);
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 = (installed = true) => {
if (!selectedExtension) return;
const { id } = selectedExtension;
setList((prev) => {
return prev.map((item) => {
if (item.id === id) {
return { ...item, installed };
}
return item;
});
});
if (selectedExtension?.id === id) {
setSelectedExtension({
...selectedExtension,
installed,
});
}
};
const handleInstall = async () => {
if (!selectedExtension) return;
const { id, name, installed } = selectedExtension;
try {
if (installed || installingExtensions.includes(id)) return;
setInstallingExtensions(installingExtensions.concat(id));
await platformAdapter.invokeBackend("install_extension", { id });
toggleInstall();
addError(
`${name} ${t("extensionStore.hints.installationCompleted")}`,
"info"
);
} catch (error) {
addError(String(error), "error");
} finally {
setInstallingExtensions(
installingExtensions.filter((item) => item !== id)
);
}
};
const handleUnInstall = async () => {
if (!selectedExtension) return;
const { id, name, installed, developer } = selectedExtension;
try {
if (!installed || uninstallingExtensions.includes(id)) return;
setUninstallingExtensions(uninstallingExtensions.concat(id));
await platformAdapter.invokeBackend("uninstall_extension", {
developer: developer.id,
extensionId: id,
});
toggleInstall(false);
addError(
`${name} ${t("extensionStore.hints.uninstallationCompleted")}`,
"info"
);
} catch (error) {
addError(String(error), "error");
} finally {
setUninstallingExtensions(
uninstallingExtensions.filter((item) => item !== id)
);
}
};
return (
<div className="h-full text-sm p-2 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 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">
<img src={icon} className="size-[20px]" />
<span>{name}</span>
<span className="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;

View File

@@ -97,6 +97,8 @@ export default function ChatInput({
const textareaRef = useRef<{ reset: () => void; focus: () => void }>(null);
const { curChatEnd, connected } = useChatStore();
const { setSearchValue, visibleExtensionStore, selectedExtension } =
useSearchStore();
useEffect(() => {
const handleFocus = () => {
@@ -135,6 +137,7 @@ export default function ChatInput({
const handleInputChange = useCallback(
(value: string) => {
changeInput(value);
setSearchValue(value);
if (!isChatMode) {
onSend(value);
}
@@ -215,15 +218,16 @@ export default function ChatInput({
disabledChange={disabledChange}
/>
{!isChatMode && sourceData && (
<div
className={`absolute ${
lineCount === 1 ? "-top-[5px]" : "top-[calc(100%-25px)]"
} left-2`}
>
<VisibleKey shortcut="←" />
</div>
)}
{!isChatMode &&
(sourceData || visibleExtensionStore || selectedExtension) && (
<div
className={`absolute ${
lineCount === 1 ? "-top-[5px]" : "top-[calc(100%-25px)]"
} left-2`}
>
<VisibleKey shortcut="←" />
</div>
)}
{/*
<div
@@ -244,7 +248,8 @@ export default function ChatInput({
isTauri &&
!goAskAi &&
askAI &&
!disabledExtensions.includes("QuickAIAccess") && (
!disabledExtensions.includes("QuickAIAccess") &&
!visibleExtensionStore && (
<div className="flex items-center gap-2 text-sm text-[#AEAEAE] dark:text-[#545454] whitespace-nowrap">
<span>
{t("search.askCocoAi.title", {

View File

@@ -190,6 +190,7 @@ const InputControls = ({
return state.aiOverviewAssistant;
});
const aiOverviewShortcut = useShortcutsStore((state) => state.aiOverview);
const { visibleExtensionStore } = useSearchStore();
return (
<div
@@ -273,7 +274,8 @@ const InputControls = ({
{!disabledExtensions.includes("AIOverview") &&
isTauri &&
aiOverviewServer &&
aiOverviewAssistant && (
aiOverviewAssistant &&
!visibleExtensionStore && (
<div
className={clsx(
"inline-flex items-center gap-1 px-2 py-1 rounded-full hover:!text-[#881c94] cursor-pointer transition",

View File

@@ -8,6 +8,7 @@ import { NoResults } from "@/components/Common/UI/NoResults";
import Footer from "@/components/Common/UI/Footer";
import AskAi from "./AskAi";
import { useSearch } from "@/hooks/useSearch";
import ExtensionStore from "./ExtensionStore";
const SearchResultsPanel = memo<{
input: string;
@@ -33,7 +34,8 @@ const SearchResultsPanel = memo<{
}
}, [input, isChatMode, performSearch, sourceData]);
const { setSelectedAssistant, selectedSearchContent } = useSearchStore();
const { setSelectedAssistant, selectedSearchContent, visibleExtensionStore } =
useSearchStore();
useEffect(() => {
if (selectedSearchContent?.type === "AI Assistant") {
@@ -46,6 +48,7 @@ const SearchResultsPanel = memo<{
}
}, [selectedSearchContent]);
if (visibleExtensionStore) return <ExtensionStore />;
if (goAskAi) return <AskAi />;
if (suggests.length === 0) return <NoResults />;

View File

@@ -14,7 +14,16 @@ export default function SearchIcons({
isChatMode,
assistant,
}: SearchIconsProps) {
const { sourceData, setSourceData, goAskAi, setGoAskAi } = useSearchStore();
const {
sourceData,
setSourceData,
goAskAi,
setGoAskAi,
visibleExtensionStore,
setVisibleExtensionStore,
visibleExtensionDetail,
setVisibleExtensionDetail,
} = useSearchStore();
if (isChatMode) {
return null;
@@ -49,11 +58,21 @@ export default function SearchIcons({
);
}
if (sourceData) {
if (sourceData || visibleExtensionStore || visibleExtensionDetail) {
return (
<ArrowBigLeft
className="w-4 h-4 text-[#ccc] dark:text-[#d8d8d8] cursor-pointer"
onClick={() => setSourceData(undefined)}
onClick={() => {
if (visibleExtensionDetail) {
return setVisibleExtensionDetail(false);
}
if (visibleExtensionStore) {
return setVisibleExtensionStore(false);
}
setSourceData(void 0);
}}
/>
);
}

View File

@@ -26,7 +26,7 @@ const Content = () => {
interface ItemProps extends Extension {
level: number;
parentId?: ExtensionId;
parentAuthor?: string;
parentDeveloper?: string;
parentDisabled?: boolean;
}
@@ -44,14 +44,14 @@ const Item: FC<ItemProps> = (props) => {
const {
id,
icon,
title,
name,
type,
level,
platforms,
author,
developer,
enabled,
parentId,
parentAuthor,
parentDeveloper,
parentDisabled,
} = props;
const { rootState } = useContext(ExtensionsContext);
@@ -64,7 +64,7 @@ const Item: FC<ItemProps> = (props) => {
const [selfDisabled, setSelfDisabled] = useState(!enabled);
const bundleId = {
author: author ?? parentAuthor,
developer: developer ?? parentDeveloper,
extension_id: level === 1 ? id : parentId,
sub_extension_id: level === 1 ? void 0 : id,
};
@@ -100,7 +100,7 @@ const Item: FC<ItemProps> = (props) => {
state.loading = false;
return sortBy(subExtensions, ["title"]);
return sortBy(subExtensions, ["name"]);
};
const handleExpand = async (event: MouseEvent) => {
@@ -247,15 +247,15 @@ const Item: FC<ItemProps> = (props) => {
className={clsx("flex items-center justify-end", {
"opacity-50 pointer-events-none": parentDisabled,
})}
onClick={(event) => {
event.stopPropagation();
}}
>
<SettingsToggle
label={id}
defaultChecked={enabled}
className="scale-75"
onChange={handleChange}
onClick={(event) => {
event.stopPropagation();
}}
/>
</div>
);
@@ -337,7 +337,7 @@ const Item: FC<ItemProps> = (props) => {
"opacity-50 pointer-events-none": isDisabled,
})}
>
{title}
{name}
</div>
</div>
@@ -358,7 +358,7 @@ const Item: FC<ItemProps> = (props) => {
{...item}
level={level + 1}
parentId={id}
parentAuthor={author}
parentDeveloper={developer}
parentDisabled={!enabled}
/>
);

View File

@@ -24,13 +24,13 @@ const App = () => {
useAsyncEffect(async () => {
if (!rootState.activeExtension) return;
const { id, title } = rootState.activeExtension;
const { id, name } = rootState.activeExtension;
const appMetadata = await platformAdapter.invokeBackend<Metadata>(
"get_app_metadata",
{
appPath: id,
appName: title,
appName: name,
}
);

View File

@@ -26,7 +26,7 @@ const Details = () => {
const renderContent = () => {
if (!rootState.activeExtension) return;
const { id, type } = rootState.activeExtension;
const { id, type, description } = rootState.activeExtension;
if (id === "Applications") {
return <Applications />;
@@ -56,12 +56,14 @@ const Details = () => {
if (id === "Calculator") {
return <Calculator />;
}
return <div className="text-[#999]">{description}</div>;
};
return (
<div className="flex-1 h-full overflow-auto">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{rootState.activeExtension?.title}
{rootState.activeExtension?.name}
</h2>
<div className="pr-4 pb-4 text-sm">{renderContent()}</div>

View File

@@ -1,5 +1,5 @@
import { createContext, useEffect } from "react";
import { useMount, useReactive } from "ahooks";
import { useReactive } from "ahooks";
import { useTranslation } from "react-i18next";
import type { LiteralUnion } from "type-fest";
import { cloneDeep, sortBy } from "lodash-es";
@@ -8,6 +8,10 @@ import platformAdapter from "@/utils/platformAdapter";
import Content from "./components/Content";
import Details from "./components/Details";
import { useExtensionsStore } from "@/stores/extensionsStore";
import { Button } from "@headlessui/react";
import { Plus } from "lucide-react";
import SettingsInput from "../SettingsInput";
import clsx from "clsx";
export type ExtensionId = LiteralUnion<
"Applications" | "Calculator" | "QuickAIAccess" | "AIOverview",
@@ -19,7 +23,7 @@ type ExtensionType =
| "extension"
| "application"
| "script"
| "quick_link"
| "quicklink"
| "setting"
| "calculator"
| "command"
@@ -40,7 +44,7 @@ export interface Extension {
id: ExtensionId;
type: ExtensionType;
icon: string;
title: string;
name: string;
description: string;
alias?: string;
hotkey?: string;
@@ -52,16 +56,26 @@ export interface Extension {
scripts?: Extension[];
quicklinks?: Extension[];
settings: Record<string, unknown>;
author?: string;
developer?: string;
}
type Category = LiteralUnion<
"All" | "Commands" | "Scripts" | "Apps" | "QuickLinks",
string
>;
interface State {
extensions: Extension[];
activeExtension?: Extension;
categories: Category[];
currentCategory: Category;
searchValue?: string;
}
const INITIAL_STATE: State = {
extensions: [],
categories: ["All", "Commands", "Scripts", "Apps", "QuickLinks"],
currentCategory: "All",
};
export const ExtensionsContext = createContext<{ rootState: State }>({
@@ -71,16 +85,11 @@ export const ExtensionsContext = createContext<{ rootState: State }>({
export const Extensions = () => {
const { t } = useTranslation();
const state = useReactive<State>(cloneDeep(INITIAL_STATE));
const { configId, setConfigId } = useExtensionsStore();
useMount(async () => {
const result = await platformAdapter.invokeBackend<[boolean, Extension[]]>(
"list_extensions"
);
const extensions = result[1];
state.extensions = sortBy(extensions, ["title"]);
});
useEffect(() => {
getExtensions();
}, [state.searchValue, state.currentCategory, configId]);
useEffect(() => {
const unsubscribe = useExtensionsStore.subscribe((state) => {
@@ -92,19 +101,105 @@ export const Extensions = () => {
};
});
const getExtensions = async () => {
const result = await platformAdapter.invokeBackend<[boolean, Extension[]]>(
"list_extensions",
{
query: state.searchValue,
extensionType: getExtensionType(),
listEnabled: false,
}
);
const extensions = result[1];
state.extensions = sortBy(extensions, ["name"]);
if (configId) {
const matched = extensions.find((item) => item.id === configId);
if (!matched) return;
state.activeExtension = matched;
setConfigId(void 0);
}
};
const getExtensionType = (): ExtensionType | undefined => {
switch (state.currentCategory) {
case "All":
return void 0;
case "Commands":
return "command";
case "Scripts":
return "script";
case "Apps":
return "application";
case "QuickLinks":
return "quicklink";
default:
return void 0;
}
};
return (
<ExtensionsContext.Provider
value={{
rootState: state,
}}
>
<div className="flex h-[calc(100vh-128px)] -mx-6 gap-4">
<div className="flex h-[calc(100vh-128px)] -mx-6 gap-4 text-sm">
<div className="w-2/3 h-full px-4 border-r dark:border-gray-700 overflow-auto">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{t("settings.extensions.title")}
</h2>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{t("settings.extensions.title")}
</h2>
<div>
<Button
className="flex items-center justify-center size-6 border rounded-md dark:border-gray-700 hover:!border-[#0096FB] transition"
onClick={() => {
platformAdapter.emitEvent("open-extension-store");
}}
>
<Plus className="size-4 text-[#0096FB]" />
</Button>
</div>
<div className="flex justify-between gap-6 my-4">
<div className="flex h-8 border dark:border-gray-700">
{state.categories.map((item) => {
return (
<div
key={item}
className={clsx(
"flex items-center h-full px-4 cursor-pointer",
{
"bg-[#F0F6FE] dark:bg-gray-700":
item === state.currentCategory,
}
)}
onClick={() => {
state.currentCategory = item;
}}
>
{item}
</div>
);
})}
</div>
<SettingsInput
className="flex-1"
placeholder="Search"
value={state.searchValue}
onChange={(value) => {
state.searchValue = String(value);
}}
/>
</div>
<>
<div className="flex">
<div className="flex-1">{t("settings.extensions.list.name")}</div>
@@ -125,7 +220,7 @@ export const Extensions = () => {
</div>
<Content />
</div>
</>
</div>
<Details />

View File

@@ -3,10 +3,10 @@ import clsx from "clsx";
import { isNumber } from "lodash-es";
import { FC, FocusEvent } from "react";
import { specialCharacterFiltering } from "@/utils"
import { specialCharacterFiltering } from "@/utils";
interface SettingsInputProps extends Omit<InputProps, "onChange"> {
onChange: (value?: string | number) => void;
onChange?: (value?: string | number) => void;
}
const SettingsInput: FC<SettingsInputProps> = (props) => {
@@ -40,7 +40,7 @@ const SettingsInput: FC<SettingsInputProps> = (props) => {
)}
onBlur={handleBlur}
onChange={(event) => {
const value = specialCharacterFiltering(event.target.value)
const value = specialCharacterFiltering(event.target.value);
onChange?.(value);
}}
/>

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect } from "react";
import { isMetaOrCtrlKey } from "@/utils/keyboardUtils";
import { useSearchStore } from "@/stores/searchStore";
import { useShortcutsStore } from "@/stores/shortcutsStore";
interface KeyboardHandlersProps {
isChatMode: boolean;
@@ -14,15 +14,38 @@ export function useKeyboardHandlers({
handleSubmit,
curChatEnd,
}: KeyboardHandlersProps) {
const { setSourceData } = useSearchStore();
const {
setSourceData,
visibleExtensionStore,
setVisibleExtensionStore,
visibleExtensionDetail,
setVisibleExtensionDetail,
} = useSearchStore();
const { modifierKey } = useShortcutsStore();
const getModifierKeyPressed = (event: KeyboardEvent) => {
const metaKeyPressed = event.metaKey && modifierKey === "meta";
const ctrlKeyPressed = event.ctrlKey && modifierKey === "ctrl";
const altKeyPressed = event.altKey && modifierKey === "alt";
return metaKeyPressed || ctrlKeyPressed || altKeyPressed;
};
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
// Handle ArrowLeft with meta key
if (e.code === "ArrowLeft" && isMetaOrCtrlKey(e)) {
if (e.code === "ArrowLeft" && getModifierKeyPressed(e)) {
e.preventDefault();
setSourceData(undefined);
return;
if (visibleExtensionDetail) {
return setVisibleExtensionDetail(false);
}
if (visibleExtensionStore) {
return setVisibleExtensionStore(false);
}
return setSourceData(void 0);
}
// Handle Enter without meta key requirement
@@ -31,7 +54,14 @@ export function useKeyboardHandlers({
curChatEnd && handleSubmit();
}
},
[isChatMode, handleSubmit, setSourceData, curChatEnd]
[
isChatMode,
handleSubmit,
setSourceData,
curChatEnd,
modifierKey,
visibleExtensionDetail,
]
);
useEffect(() => {
@@ -45,6 +75,7 @@ export function useKeyboardHandlers({
useEffect(() => {
return () => {
setSourceData(undefined);
setVisibleExtensionStore(false);
};
}, []);
}

View File

@@ -30,5 +30,5 @@ export const useIconfontScript = () => {
// Coco Server Icons
useScript("https://at.alicdn.com/t/c/font_4878526_cykw3et0ezd.js");
// Coco App Icons
useScript("https://at.alicdn.com/t/c/font_4934333_j1t3b1xyxkk.js");
useScript("https://at.alicdn.com/t/c/font_4934333_80wr9yn2eup.js");
};

View File

@@ -301,7 +301,10 @@
"updateAvailable": "Update available",
"select": "Select",
"open": "Open",
"powered": "Powered by Coco AI"
"powered": "Powered by Coco AI",
"install": "Install",
"details": "Details",
"uninstall": "Uninstall"
},
"input": {
"searchPlaceholder": "Search whatever you want ...",
@@ -340,7 +343,11 @@
"title": {
"calculator": "Calculator"
},
"search": "Search Operation"
"search": "Search Operation",
"details": "Details",
"install": "Install",
"uninstall": "Uninstall",
"configureExtension": "Configure Extension"
},
"askCocoAi": {
"title": "{{0}} {{1}}",
@@ -506,5 +513,32 @@
"divide": "Divide",
"remainder": "Remainder",
"expression": "Expression"
},
"extensionStore": {
"hints": {
"installationCompleted": "installation completed",
"uninstallationCompleted": "uninstallation completed"
}
},
"extensionDetail": {
"label": {
"description": "Description",
"commands": "Commands",
"tags": "Tags",
"lastUpdate": "Last Update"
},
"hints": {
"installed": "Installed"
},
"deleteDialog": {
"title": "Uninstall",
"description": "This will remove all the data and commands associated with this extension."
}
},
"deleteDialog": {
"button": {
"cancel": "Cancel",
"delete": "Delete"
}
}
}

View File

@@ -301,7 +301,10 @@
"updateAvailable": "有可用更新",
"select": "选择",
"open": "打开",
"powered": "由 Coco AI 提供支持"
"powered": "由 Coco AI 提供支持",
"install": "安装",
"details": "详情",
"uninstall": "卸载"
},
"input": {
"searchPlaceholder": "搜索任何内容...",
@@ -340,7 +343,11 @@
"title": {
"calculator": "计算器"
},
"search": "搜索操作"
"search": "搜索操作",
"details": "详情",
"install": "安装",
"uninstall": "卸载",
"configureExtension": "配置扩展"
},
"askCocoAi": {
"title": "{{0}}{{1}}",
@@ -505,5 +512,32 @@
"divide": "相除",
"remainder": "求余",
"expression": "表达式"
},
"extensionStore": {
"hints": {
"installationCompleted": "安装成功",
"uninstallationCompleted": "卸载成功"
}
},
"extensionDetail": {
"label": {
"description": "描述",
"commands": "命令",
"tags": "标签",
"lastUpdate": "最后更新时间"
},
"hints": {
"installed": "已安装"
},
"deleteDialog": {
"title": "卸载",
"description": "这将删除与该扩展相关的所有数据和命令。"
}
},
"deleteDialog": {
"button": {
"cancel": "取消",
"delete": "删除"
}
}
}

View File

@@ -15,6 +15,7 @@ import Extensions from "@/components/Settings/Extensions";
import { useConnectStore } from "@/stores/connectStore";
import platformAdapter from "@/utils/platformAdapter";
import { useAppStore } from "@/stores/appStore";
import { useExtensionsStore } from "@/stores/extensionsStore";
const tabIndexMap: { [key: string]: number } = {
general: 0,
@@ -26,6 +27,7 @@ const tabIndexMap: { [key: string]: number } = {
function SettingsPage() {
const { t } = useTranslation();
const { setConfigId } = useExtensionsStore();
useTray();
@@ -37,7 +39,7 @@ function SettingsPage() {
{ name: t("settings.tabs.about"), icon: Info },
];
const [defaultIndex, setDefaultIndex] = useState<number>(0);
const [defaultIndex, setDefaultIndex] = useState<number>(1);
useEffect(() => {
const unlisten = listen("tab_index", (event) => {
@@ -56,10 +58,20 @@ function SettingsPage() {
platformAdapter.emitEvent("change-app-store", state);
});
const unlisten2 = platformAdapter.listenEvent(
"config-extension",
({ payload }) => {
platformAdapter.showWindow();
setDefaultIndex(1);
setConfigId(payload);
}
);
return () => {
unlisten.then((fn) => fn());
unsubscribeConnect();
unsubscribeAppStore();
unlisten.then((fn) => fn());
unlisten2.then((fn) => fn());
};
}, []);

View File

@@ -117,7 +117,10 @@ export default function Layout() {
useMount(async () => {
const result = await platformAdapter.invokeBackend<[boolean, Extension[]]>(
"list_extensions"
"list_extensions",
{
listEnabled: false,
}
);
if (!isArray(result)) return;

View File

@@ -19,6 +19,8 @@ export type IExtensionsStore = {
setAiOverviewDelay: (aiOverviewDelay: number) => void;
aiOverviewMinQuantity: number;
setAiOverviewMinQuantity: (aiOverviewMinQuantity: number) => void;
configId?: string;
setConfigId: (configId?: string) => void;
};
export const useExtensionsStore = create<IExtensionsStore>()(
@@ -53,6 +55,9 @@ export const useExtensionsStore = create<IExtensionsStore>()(
setAiOverviewMinQuantity(aiOverviewMinQuantity) {
return set({ aiOverviewMinQuantity });
},
setConfigId(configId) {
return set({ configId });
},
}),
{
name: "extensions-store",

View File

@@ -1,3 +1,4 @@
import { SearchExtensionItem } from "@/components/Search/ExtensionStore";
import { create } from "zustand";
import { persist } from "zustand/middleware";
@@ -28,6 +29,18 @@ export type ISearchStore = {
setEnabledAiOverview: (enabledAiOverview: boolean) => void;
askAiAssistantId?: string;
setAskAiAssistantId: (askAiAssistantId?: string) => void;
visibleExtensionStore: boolean;
setVisibleExtensionStore: (visibleExtensionStore: boolean) => void;
searchValue: string;
setSearchValue: (searchValue: string) => void;
selectedExtension?: SearchExtensionItem;
setSelectedExtension: (selectedExtension?: SearchExtensionItem) => void;
installingExtensions: string[];
setInstallingExtensions: (installingExtensions: string[]) => void;
uninstallingExtensions: string[];
setUninstallingExtensions: (uninstallingExtensions: string[]) => void;
visibleExtensionDetail: boolean;
setVisibleExtensionDetail: (visibleExtensionDetail: boolean) => void;
};
export const useSearchStore = create<ISearchStore>()(
@@ -70,6 +83,29 @@ export const useSearchStore = create<ISearchStore>()(
setAskAiAssistantId: (askAiAssistantId) => {
return set({ askAiAssistantId });
},
visibleExtensionStore: false,
setVisibleExtensionStore: (visibleExtensionStore) => {
return set({ visibleExtensionStore });
},
searchValue: "",
setSearchValue: (searchValue) => {
return set({ searchValue });
},
setSelectedExtension(selectedExtension) {
return set({ selectedExtension });
},
installingExtensions: [],
setInstallingExtensions: (installingExtensions) => {
return set({ installingExtensions });
},
uninstallingExtensions: [],
setUninstallingExtensions: (uninstallingExtensions) => {
return set({ uninstallingExtensions });
},
visibleExtensionDetail: false,
setVisibleExtensionDetail: (visibleExtensionDetail) => {
return set({ visibleExtensionDetail });
},
}),
{
name: "search-store",

View File

@@ -44,6 +44,10 @@ export interface EventPayloads {
"quick-ai-access-client-id": any;
"ai-overview-client-id": any;
"change-app-store": IAppStore;
"open-extension-store": void;
"install-extension": void;
"uninstall-extension": void;
"config-extension": string;
}
// Window operation interface

View File

@@ -235,7 +235,9 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
async openSearchItem(data) {
const { invoke } = await import("@tauri-apps/api/core");
if (data.type === "AI Assistant") {
console.log("data", data);
if (data?.type === "AI Assistant" || data?.id === "extension_store") {
const textarea = document.querySelector("#search-textarea");
if (!(textarea instanceof HTMLTextAreaElement)) return;