mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-16 11:37:47 +01:00
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:
@@ -15,6 +15,7 @@ Information about release notes of Coco Server is provided here.
|
||||
|
||||
- feat: support `Tab` and `Enter` for delete dialog buttons #700
|
||||
- feat: add check for updates #701
|
||||
- feat: impl extension store #699
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tauri-plugin-fs-pro-api": "^2.4.0",
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0",
|
||||
"tauri-plugin-screenshots-api": "^2.2.0",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -134,6 +134,9 @@ importers:
|
||||
remark-math:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
tailwind-merge:
|
||||
specifier: ^3.3.1
|
||||
version: 3.3.1
|
||||
tauri-plugin-fs-pro-api:
|
||||
specifier: ^2.4.0
|
||||
version: 2.4.0
|
||||
@@ -3447,6 +3450,9 @@ packages:
|
||||
tabbable@6.2.0:
|
||||
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
|
||||
|
||||
tailwind-merge@3.3.1:
|
||||
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
|
||||
|
||||
tailwindcss@3.4.17:
|
||||
resolution: {integrity: sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -7248,6 +7254,8 @@ snapshots:
|
||||
|
||||
tabbable@6.2.0: {}
|
||||
|
||||
tailwind-merge@3.3.1: {}
|
||||
|
||||
tailwindcss@3.4.17:
|
||||
dependencies:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
|
||||
201
src-tauri/Cargo.lock
generated
201
src-tauri/Cargo.lock
generated
@@ -17,6 +17,17 @@ version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.7.8"
|
||||
@@ -654,6 +665,25 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
|
||||
dependencies = [
|
||||
"bzip2-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2-sys"
|
||||
version = "0.1.13+1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cairo-rs"
|
||||
version = "0.18.5"
|
||||
@@ -814,6 +844,16 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "3.2.25"
|
||||
@@ -906,6 +946,7 @@ dependencies = [
|
||||
"tungstenite 0.24.0",
|
||||
"url",
|
||||
"walkdir",
|
||||
"zip 4.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1013,6 +1054,12 @@ dependencies = [
|
||||
"tiny-keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
@@ -1285,6 +1332,12 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deflate64"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.4.0"
|
||||
@@ -1348,6 +1401,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1790,6 +1844,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"libz-rs-sys",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
@@ -2527,6 +2582,15 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hostname"
|
||||
version = "0.3.1"
|
||||
@@ -2970,6 +3034,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "interpolate_name"
|
||||
version = "0.2.4"
|
||||
@@ -3235,6 +3308,26 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "liblzma"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66352d7a8ac12d4877b6e6ea5a9b7650ee094257dc40889955bea5bc5b08c1d0"
|
||||
dependencies = [
|
||||
"liblzma-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "liblzma-sys"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01b9596486f6d60c3bbe644c0e1be1aa6ccc472ad630fe8927b456973d7cb736"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.3"
|
||||
@@ -3246,6 +3339,15 @@ dependencies = [
|
||||
"redox_syscall",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libz-rs-sys"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221"
|
||||
dependencies = [
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linked-hash-map"
|
||||
version = "0.5.6"
|
||||
@@ -4293,6 +4395,16 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.1"
|
||||
@@ -5461,6 +5573,7 @@ version = "1.0.140"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
|
||||
dependencies = [
|
||||
"indexmap 2.9.0",
|
||||
"itoa 1.0.15",
|
||||
"memchr",
|
||||
"ryu",
|
||||
@@ -6446,7 +6559,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.59.0",
|
||||
"zip",
|
||||
"zip 2.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8471,6 +8584,20 @@ name = "zeroize"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
@@ -8518,6 +8645,78 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "153a6fff49d264c4babdcfa6b4d534747f520e56e8f0f384f3b808c4b64cc1fd"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"arbitrary",
|
||||
"bzip2",
|
||||
"constant_time_eq",
|
||||
"crc32fast",
|
||||
"deflate64",
|
||||
"flate2",
|
||||
"getrandom 0.3.2",
|
||||
"hmac",
|
||||
"indexmap 2.9.0",
|
||||
"liblzma",
|
||||
"memchr",
|
||||
"pbkdf2",
|
||||
"sha1",
|
||||
"time",
|
||||
"zeroize",
|
||||
"zopfli",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "7.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
|
||||
dependencies = [
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.0.15+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.4.12"
|
||||
|
||||
@@ -49,7 +49,7 @@ tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
# Need `arbitrary_precision` feature to support storing u128
|
||||
# see: https://docs.rs/serde_json/latest/serde_json/struct.Number.html#method.from_u128
|
||||
serde_json = { version = "1", features = ["arbitrary_precision"] }
|
||||
serde_json = { version = "1", features = ["arbitrary_precision", "preserve_order"] }
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-websocket = "2"
|
||||
tauri-plugin-deep-link = "2.0.0"
|
||||
@@ -83,7 +83,6 @@ walkdir = "2"
|
||||
log = "0.4"
|
||||
strsim = "0.10"
|
||||
futures-util = "0.3.31"
|
||||
url = "2.5.2"
|
||||
http = "1.1.0"
|
||||
tungstenite = "0.24.0"
|
||||
tokio-util = "0.7.14"
|
||||
@@ -101,6 +100,8 @@ regex = "1.11.1"
|
||||
borrowme = "0.0.15"
|
||||
tauri-plugin-opener = "2"
|
||||
async-recursion = "1.1.1"
|
||||
zip = "4.0.0"
|
||||
url = "2.5.2"
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
|
||||
|
||||
@@ -51,7 +51,9 @@ impl OnOpened {
|
||||
const WHITESPACE: &str = " ";
|
||||
let mut ret = action.exec.clone();
|
||||
ret.push_str(WHITESPACE);
|
||||
ret.push_str(action.args.join(WHITESPACE).as_str());
|
||||
if let Some(ref args) = action.args {
|
||||
ret.push_str(args.join(WHITESPACE).as_str());
|
||||
}
|
||||
|
||||
ret
|
||||
}
|
||||
@@ -80,7 +82,9 @@ pub(crate) async fn open(on_opened: OnOpened) -> Result<(), String> {
|
||||
}
|
||||
OnOpened::Command { action } => {
|
||||
let mut cmd = Command::new(action.exec);
|
||||
cmd.args(action.args);
|
||||
if let Some(args) = action.args {
|
||||
cmd.args(args);
|
||||
}
|
||||
let output = cmd.output().map_err(|e| e.to_string())?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
|
||||
@@ -4,7 +4,7 @@ pub(super) const EXTENSION_ID: &str = "AIOverview";
|
||||
pub(crate) const PLUGIN_JSON_FILE: &str = r#"
|
||||
{
|
||||
"id": "AIOverview",
|
||||
"title": "AI Overview",
|
||||
"name": "AI Overview",
|
||||
"description": "...",
|
||||
"icon": "font_a-AIOverview",
|
||||
"type": "ai_extension",
|
||||
|
||||
@@ -39,7 +39,7 @@ pub(crate) const PLUGIN_JSON_FILE: &str = r#"
|
||||
{
|
||||
"id": "Applications",
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"title": "Applications",
|
||||
"name": "Applications",
|
||||
"description": "Application search",
|
||||
"icon": "font_Application",
|
||||
"type": "group",
|
||||
|
||||
@@ -1038,9 +1038,9 @@ pub async fn get_app_list<R: Runtime>(
|
||||
|
||||
let app_entry = Extension {
|
||||
id: path,
|
||||
title: name,
|
||||
name,
|
||||
platforms: None,
|
||||
author: None,
|
||||
developer: None,
|
||||
// Leave it empty as it won't be used
|
||||
description: String::new(),
|
||||
icon: icon_path,
|
||||
@@ -1054,6 +1054,9 @@ pub async fn get_app_list<R: Runtime>(
|
||||
hotkey,
|
||||
enabled,
|
||||
settings: None,
|
||||
screenshots: None,
|
||||
url: None,
|
||||
version: None,
|
||||
};
|
||||
|
||||
app_entries.push(app_entry);
|
||||
|
||||
@@ -17,7 +17,7 @@ pub(crate) const DATA_SOURCE_ID: &str = "Calculator";
|
||||
pub(crate) const PLUGIN_JSON_FILE: &str = r#"
|
||||
{
|
||||
"id": "Calculator",
|
||||
"title": "Calculator",
|
||||
"name": "Calculator",
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"description": "...",
|
||||
"icon": "font_Calculator",
|
||||
|
||||
@@ -203,7 +203,7 @@ pub(super) async fn init_built_in_extension<R: Runtime>(
|
||||
}
|
||||
|
||||
pub(crate) fn is_extension_built_in(bundle_id: &ExtensionBundleIdBorrowed<'_>) -> bool {
|
||||
bundle_id.author.is_none()
|
||||
bundle_id.developer.is_none()
|
||||
}
|
||||
|
||||
pub(crate) async fn enable_built_in_extension(
|
||||
|
||||
@@ -3,7 +3,7 @@ pub(super) const EXTENSION_ID: &str = "QuickAIAccess";
|
||||
pub(crate) const PLUGIN_JSON_FILE: &str = r#"
|
||||
{
|
||||
"id": "QuickAIAccess",
|
||||
"title": "Quick AI Access",
|
||||
"name": "Quick AI Access",
|
||||
"description": "...",
|
||||
"icon": "font_a-QuickAIAccess",
|
||||
"type": "ai_extension",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub(crate) mod built_in;
|
||||
pub(crate) mod store;
|
||||
mod third_party;
|
||||
|
||||
use crate::common::document::OnOpened;
|
||||
@@ -18,6 +19,10 @@ pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
|
||||
const PLUGIN_JSON_FILE_NAME: &str = "plugin.json";
|
||||
const ASSETS_DIRECTORY_FILE_NAME: &str = "assets";
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Copy, Clone, Hash, PartialEq, Eq, Display)]
|
||||
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
|
||||
enum Platform {
|
||||
@@ -33,17 +38,17 @@ enum Platform {
|
||||
pub struct Extension {
|
||||
/// Extension ID.
|
||||
///
|
||||
/// The ID doesn't uniquely identifies an extension; Its bundle ID (ID & author) does.
|
||||
/// The ID doesn't uniquely identifies an extension; Its bundle ID (ID & developer) does.
|
||||
id: String,
|
||||
/// Extension name.
|
||||
title: String,
|
||||
/// ID of the author.
|
||||
name: String,
|
||||
/// ID of the developer.
|
||||
///
|
||||
/// * For built-in extensions, this will always be None.
|
||||
/// * For third-party first-layer extensions, the on-disk plugin.json file
|
||||
/// won't contain this field, but we will set this field for them after reading them into the memory.
|
||||
/// * For third-party sub extensions, this field will be None.
|
||||
author: Option<String>,
|
||||
developer: Option<String>,
|
||||
/// Platforms supported by this extension.
|
||||
///
|
||||
/// If `None`, then this extension can be used on all the platforms.
|
||||
@@ -91,17 +96,23 @@ pub struct Extension {
|
||||
hotkey: Option<String>,
|
||||
|
||||
/// Is this extension enabled.
|
||||
#[serde(default = "default_true")]
|
||||
enabled: bool,
|
||||
|
||||
/// Extension settings
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
settings: Option<Json>,
|
||||
|
||||
// We do not care about these fields, just take it regardless of what it is.
|
||||
screenshots: Option<Json>,
|
||||
url: Option<Json>,
|
||||
version: Option<Json>,
|
||||
}
|
||||
|
||||
/// Bundle ID uniquely identifies an extension.
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq, Clone)]
|
||||
pub(crate) struct ExtensionBundleId {
|
||||
author: Option<String>,
|
||||
developer: Option<String>,
|
||||
extension_id: String,
|
||||
sub_extension_id: Option<String>,
|
||||
}
|
||||
@@ -111,7 +122,7 @@ impl Borrow for ExtensionBundleId {
|
||||
|
||||
fn borrow(&self) -> Self::Target<'_> {
|
||||
ExtensionBundleIdBorrowed {
|
||||
author: self.author.as_deref(),
|
||||
developer: self.developer.as_deref(),
|
||||
extension_id: &self.extension_id,
|
||||
sub_extension_id: self.sub_extension_id.as_deref(),
|
||||
}
|
||||
@@ -121,7 +132,7 @@ impl Borrow for ExtensionBundleId {
|
||||
/// Reference version of `ExtensionBundleId`.
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
pub(crate) struct ExtensionBundleIdBorrowed<'ext> {
|
||||
author: Option<&'ext str>,
|
||||
developer: Option<&'ext str>,
|
||||
extension_id: &'ext str,
|
||||
sub_extension_id: Option<&'ext str>,
|
||||
}
|
||||
@@ -131,7 +142,7 @@ impl ToOwned for ExtensionBundleIdBorrowed<'_> {
|
||||
|
||||
fn to_owned(&self) -> Self::Owned {
|
||||
ExtensionBundleId {
|
||||
author: self.author.map(|s| s.to_string()),
|
||||
developer: self.developer.map(|s| s.to_string()),
|
||||
extension_id: self.extension_id.to_string(),
|
||||
sub_extension_id: self.sub_extension_id.map(|s| s.to_string()),
|
||||
}
|
||||
@@ -140,7 +151,7 @@ impl ToOwned for ExtensionBundleIdBorrowed<'_> {
|
||||
|
||||
impl<'ext> PartialEq<ExtensionBundleIdBorrowed<'ext>> for ExtensionBundleId {
|
||||
fn eq(&self, other: &ExtensionBundleIdBorrowed<'ext>) -> bool {
|
||||
self.author.as_deref() == other.author
|
||||
self.developer.as_deref() == other.developer
|
||||
&& self.extension_id == other.extension_id
|
||||
&& self.sub_extension_id.as_deref() == other.sub_extension_id
|
||||
}
|
||||
@@ -148,7 +159,7 @@ impl<'ext> PartialEq<ExtensionBundleIdBorrowed<'ext>> for ExtensionBundleId {
|
||||
|
||||
impl<'ext> PartialEq<ExtensionBundleId> for ExtensionBundleIdBorrowed<'ext> {
|
||||
fn eq(&self, other: &ExtensionBundleId) -> bool {
|
||||
self.author == other.author.as_deref()
|
||||
self.developer == other.developer.as_deref()
|
||||
&& self.extension_id == other.extension_id
|
||||
&& self.sub_extension_id == other.sub_extension_id.as_deref()
|
||||
}
|
||||
@@ -159,7 +170,7 @@ impl Extension {
|
||||
/// set to `None`, this may not be what you want.
|
||||
pub(crate) fn bundle_id_borrowed(&self) -> ExtensionBundleIdBorrowed<'_> {
|
||||
ExtensionBundleIdBorrowed {
|
||||
author: self.author.as_deref(),
|
||||
developer: self.developer.as_deref(),
|
||||
extension_id: &self.id,
|
||||
sub_extension_id: None,
|
||||
}
|
||||
@@ -205,18 +216,12 @@ impl Extension {
|
||||
}
|
||||
}
|
||||
if let Some(ref scripts) = self.scripts {
|
||||
if let Some(sub_ext) = scripts
|
||||
.iter()
|
||||
.find(|script| script.id == sub_extension_id)
|
||||
{
|
||||
if let Some(sub_ext) = scripts.iter().find(|script| script.id == sub_extension_id) {
|
||||
return Some(sub_ext);
|
||||
}
|
||||
}
|
||||
if let Some(ref quick_links) = self.quicklinks {
|
||||
if let Some(sub_ext) = quick_links
|
||||
.iter()
|
||||
.find(|link| link.id == sub_extension_id)
|
||||
{
|
||||
if let Some(sub_ext) = quick_links.iter().find(|link| link.id == sub_extension_id) {
|
||||
return Some(sub_ext);
|
||||
}
|
||||
}
|
||||
@@ -264,7 +269,7 @@ impl Extension {
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub(crate) struct CommandAction {
|
||||
pub(crate) exec: String,
|
||||
pub(crate) args: Vec<String>,
|
||||
pub(crate) args: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
@@ -301,12 +306,151 @@ impl ExtensionType {
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to filter out the extensions that do not satisfy the specifies conditions.
|
||||
///
|
||||
/// used in `list_extensions()`
|
||||
fn filter_out_extensions(
|
||||
extensions: &mut Vec<Extension>,
|
||||
query: Option<&str>,
|
||||
extension_type: Option<ExtensionType>,
|
||||
list_enabled: bool,
|
||||
) {
|
||||
// apply `list_enabled`
|
||||
if list_enabled {
|
||||
extensions.retain(|ext| ext.enabled);
|
||||
for extension in extensions.iter_mut() {
|
||||
if extension.r#type.contains_sub_items() {
|
||||
if let Some(ref mut commands) = extension.commands {
|
||||
commands.retain(|cmd| cmd.enabled);
|
||||
}
|
||||
if let Some(ref mut scripts) = extension.scripts {
|
||||
scripts.retain(|script| script.enabled);
|
||||
}
|
||||
if let Some(ref mut quicklinks) = extension.quicklinks {
|
||||
quicklinks.retain(|link| link.enabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// apply extension type filter to non-group/extension extensions
|
||||
if let Some(extension_type) = extension_type {
|
||||
assert!(
|
||||
extension_type != ExtensionType::Group && extension_type != ExtensionType::Extension,
|
||||
"filtering in folder extensions is pointless"
|
||||
);
|
||||
|
||||
extensions.retain(|ext| {
|
||||
let ty = ext.r#type;
|
||||
ty == ExtensionType::Group || ty == ExtensionType::Extension || ty == extension_type
|
||||
});
|
||||
|
||||
// Filter sub-extensions to only include the requested type
|
||||
for extension in extensions.iter_mut() {
|
||||
if extension.r#type.contains_sub_items() {
|
||||
if let Some(ref mut commands) = extension.commands {
|
||||
commands.retain(|cmd| cmd.r#type == extension_type);
|
||||
}
|
||||
if let Some(ref mut scripts) = extension.scripts {
|
||||
scripts.retain(|script| script.r#type == extension_type);
|
||||
}
|
||||
if let Some(ref mut quicklinks) = extension.quicklinks {
|
||||
quicklinks.retain(|link| link.r#type == extension_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Application is special, technically, it should never be filtered out by
|
||||
// this condition. But if our users will be surprising if they choose a
|
||||
// non-Application type and see it in the results. So we do this to remedy the
|
||||
// issue
|
||||
if let Some(idx) = extensions.iter().position(|ext| {
|
||||
ext.developer.is_none()
|
||||
&& ext.id == built_in::application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
|
||||
}) {
|
||||
if extension_type != ExtensionType::Application {
|
||||
extensions.remove(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// apply query filter
|
||||
if let Some(query) = query {
|
||||
let match_closure = |ext: &Extension| {
|
||||
let lowercase_title = ext.name.to_lowercase();
|
||||
let lowercase_alias = ext.alias.as_ref().map(|alias| alias.to_lowercase());
|
||||
let lowercase_query = query.to_lowercase();
|
||||
|
||||
lowercase_title.contains(&lowercase_query)
|
||||
|| lowercase_alias.map_or(false, |alias| alias.contains(&lowercase_query))
|
||||
};
|
||||
|
||||
extensions.retain(|ext| {
|
||||
if ext.r#type.contains_sub_items() {
|
||||
// Keep all group/extension types
|
||||
true
|
||||
} else {
|
||||
// Apply filter to non-group/extension types
|
||||
match_closure(ext)
|
||||
}
|
||||
});
|
||||
|
||||
// Filter sub-extensions in groups and extensions
|
||||
for extension in extensions.iter_mut() {
|
||||
if extension.r#type.contains_sub_items() {
|
||||
if let Some(ref mut commands) = extension.commands {
|
||||
commands.retain(&match_closure);
|
||||
}
|
||||
if let Some(ref mut scripts) = extension.scripts {
|
||||
scripts.retain(&match_closure);
|
||||
}
|
||||
if let Some(ref mut quicklinks) = extension.quicklinks {
|
||||
quicklinks.retain(&match_closure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove parent extensions (Group/Extension types) that have no sub-items after filtering
|
||||
extensions.retain(|ext| {
|
||||
if !ext.r#type.contains_sub_items() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// We don't do this filter to applications since it is always empty, load at runtime.
|
||||
if ext.developer.is_none()
|
||||
&& ext.id == built_in::application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
let has_commands = ext
|
||||
.commands
|
||||
.as_ref()
|
||||
.map_or(false, |commands| !commands.is_empty());
|
||||
let has_scripts = ext
|
||||
.scripts
|
||||
.as_ref()
|
||||
.map_or(false, |scripts| !scripts.is_empty());
|
||||
let has_quicklinks = ext
|
||||
.quicklinks
|
||||
.as_ref()
|
||||
.map_or(false, |quicklinks| !quicklinks.is_empty());
|
||||
|
||||
has_commands || has_scripts || has_quicklinks
|
||||
});
|
||||
}
|
||||
|
||||
/// Return value:
|
||||
///
|
||||
/// * boolean: indicates if we found any invalid extensions
|
||||
/// * Vec<Extension>: loaded extensions
|
||||
#[tauri::command]
|
||||
pub(crate) async fn list_extensions() -> Result<(bool, Vec<Extension>), String> {
|
||||
pub(crate) async fn list_extensions(
|
||||
query: Option<String>,
|
||||
extension_type: Option<ExtensionType>,
|
||||
list_enabled: bool,
|
||||
) -> Result<(bool, Vec<Extension>), String> {
|
||||
log::trace!("loading extensions");
|
||||
|
||||
let third_party_dir = third_party::THIRD_PARTY_EXTENSIONS_DIRECTORY.as_path();
|
||||
@@ -321,12 +465,19 @@ pub(crate) async fn list_extensions() -> Result<(bool, Vec<Extension>), String>
|
||||
let built_in_extensions = built_in::list_built_in_extensions().await?;
|
||||
|
||||
let found_invalid_extension = third_party_found_invalid_extension;
|
||||
let extensions = {
|
||||
let mut extensions = {
|
||||
third_party_extensions.extend(built_in_extensions);
|
||||
|
||||
third_party_extensions
|
||||
};
|
||||
|
||||
filter_out_extensions(
|
||||
&mut extensions,
|
||||
query.as_deref(),
|
||||
extension_type,
|
||||
list_enabled,
|
||||
);
|
||||
|
||||
Ok((found_invalid_extension, extensions))
|
||||
}
|
||||
|
||||
@@ -343,6 +494,9 @@ pub(crate) async fn init_extensions(mut extensions: Vec<Extension>) -> Result<()
|
||||
)
|
||||
.await?;
|
||||
|
||||
// extension store
|
||||
search_source_registry_tauri_state .register_source(store::ExtensionStore).await;
|
||||
|
||||
// Init the built-in enabled extensions
|
||||
for built_in_extension in extensions
|
||||
.extract_if(.., |ext| {
|
||||
@@ -451,7 +605,7 @@ pub(crate) async fn is_extension_enabled(bundle_id: ExtensionBundleId) -> Result
|
||||
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").is_extension_enabled(&bundle_id_borrowed).await
|
||||
}
|
||||
|
||||
fn canonicalize_relative_icon_path(
|
||||
pub(crate) fn canonicalize_relative_icon_path(
|
||||
extension_dir: &Path,
|
||||
extension: &mut Extension,
|
||||
) -> Result<(), String> {
|
||||
@@ -570,8 +724,8 @@ fn alter_extension_json_file(
|
||||
let json_file_path = {
|
||||
let mut path = extension_directory.to_path_buf();
|
||||
|
||||
if let Some(author) = bundle_id.author {
|
||||
path.push(author);
|
||||
if let Some(developer) = bundle_id.developer {
|
||||
path.push(developer);
|
||||
}
|
||||
path.push(bundle_id.extension_id);
|
||||
path.push(PLUGIN_JSON_FILE_NAME);
|
||||
|
||||
358
src-tauri/src/extension/store.rs
Normal file
358
src-tauri/src/extension/store.rs
Normal file
@@ -0,0 +1,358 @@
|
||||
//! Extension store related stuff.
|
||||
|
||||
use crate::extension::canonicalize_relative_icon_path;
|
||||
use super::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use crate::common::document::DataSourceReference;
|
||||
use crate::common::document::Document;
|
||||
use crate::common::error::SearchError;
|
||||
use crate::common::search::QueryResponse;
|
||||
use crate::common::search::QuerySource;
|
||||
use crate::common::search::SearchQuery;
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::extension::third_party::THIRD_PARTY_EXTENSIONS_DIRECTORY;
|
||||
use crate::extension::Extension;
|
||||
use crate::extension::PLUGIN_JSON_FILE_NAME;
|
||||
use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::Map as JsonObject;
|
||||
use serde_json::Value as Json;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
const DATA_SOURCE_ID: &str = "extension_store";
|
||||
|
||||
pub(crate) struct ExtensionStore;
|
||||
|
||||
#[async_trait]
|
||||
impl SearchSource for ExtensionStore {
|
||||
fn get_type(&self) -> QuerySource {
|
||||
QuerySource {
|
||||
r#type: LOCAL_QUERY_SOURCE_TYPE.into(),
|
||||
name: hostname::get()
|
||||
.unwrap_or(DATA_SOURCE_ID.into())
|
||||
.to_string_lossy()
|
||||
.into(),
|
||||
id: DATA_SOURCE_ID.into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
|
||||
const SCORE: f64 = 2000.0;
|
||||
|
||||
let Some(query_string) = query.query_strings.get("query") else {
|
||||
return Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
});
|
||||
};
|
||||
|
||||
let lowercase_query_string = query_string.to_lowercase();
|
||||
let expected_str = "extension store";
|
||||
|
||||
if expected_str.contains(&lowercase_query_string) {
|
||||
let doc = Document {
|
||||
id: DATA_SOURCE_ID.to_string(),
|
||||
category: Some(DATA_SOURCE_ID.to_string()),
|
||||
title: Some("Extension Store".to_string()),
|
||||
icon: Some("font_Store".to_string()),
|
||||
source: Some(DataSourceReference {
|
||||
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
||||
name: Some(DATA_SOURCE_ID.into()),
|
||||
id: Some(DATA_SOURCE_ID.into()),
|
||||
icon: None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: vec![(doc, SCORE)],
|
||||
total_hits: 1,
|
||||
})
|
||||
} else {
|
||||
Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the client since it caches connections internally.
|
||||
static CLIENT: LazyLock<Client> = LazyLock::new(|| Client::new());
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn search_extension(
|
||||
query_params: Option<Vec<String>>,
|
||||
) -> Result<Vec<Json>, String> {
|
||||
let query_params: Vec<(&str, &str)> = match query_params {
|
||||
Some(ref v) => {
|
||||
let mut parsed = Vec::new();
|
||||
for parameter in v.iter() {
|
||||
let (key, value) = parameter
|
||||
.split_once('=')
|
||||
.expect("query parameter should contain a '='");
|
||||
parsed.push((key, value));
|
||||
}
|
||||
|
||||
parsed
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
let response = CLIENT
|
||||
.get("http://infini.tpddns.cn:27200/store/extension/_search")
|
||||
.query(&query_params)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send request: {:?}", e))?;
|
||||
|
||||
// The response of a ES style search request
|
||||
let mut response: JsonObject<String, Json> = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {:?}", e))?;
|
||||
|
||||
let hits_json = response
|
||||
.remove("hits")
|
||||
.expect("the JSON response should contain field [hits]");
|
||||
let mut hits = match hits_json {
|
||||
Json::Object(obj) => obj,
|
||||
_ => panic!(
|
||||
"field [hits] should be a JSON object, but it is not, value: [{}]",
|
||||
hits_json
|
||||
),
|
||||
};
|
||||
|
||||
let Some(hits_hits_json) = hits.remove("hits") else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let hits_hits = match hits_hits_json {
|
||||
Json::Array(arr) => arr,
|
||||
_ => panic!(
|
||||
"field [hits.hits] should be an array, but it is not, value: [{}]",
|
||||
hits_hits_json
|
||||
),
|
||||
};
|
||||
|
||||
let mut extensions = Vec::with_capacity(hits_hits.len());
|
||||
for hit in hits_hits {
|
||||
let mut hit_obj = match hit {
|
||||
Json::Object(obj) => obj,
|
||||
_ => panic!(
|
||||
"each hit in [hits.hits] should be a JSON object, but it is not, value: [{}]",
|
||||
hit
|
||||
),
|
||||
};
|
||||
let source = hit_obj
|
||||
.remove("_source")
|
||||
.expect("each hit should contain field [_source]");
|
||||
|
||||
let mut source_obj = match source {
|
||||
Json::Object(obj) => obj,
|
||||
_ => panic!(
|
||||
"field [_source] should be a JSON object, but it is not, value: [{}]",
|
||||
source
|
||||
),
|
||||
};
|
||||
|
||||
let developer_id = source_obj
|
||||
.get("developer")
|
||||
.and_then(|dev| dev.get("id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.expect("developer.id should exist")
|
||||
.to_string();
|
||||
|
||||
let extension_id = source_obj
|
||||
.get("id")
|
||||
.and_then(|id| id.as_str())
|
||||
.expect("extension id should exist")
|
||||
.to_string();
|
||||
|
||||
let installed = is_extension_installed(developer_id, extension_id).await;
|
||||
source_obj.insert("installed".to_string(), Json::Bool(installed));
|
||||
|
||||
extensions.push(Json::Object(source_obj));
|
||||
}
|
||||
|
||||
Ok(extensions)
|
||||
}
|
||||
|
||||
async fn is_extension_installed(developer: String, extension_id: String) -> bool {
|
||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.get()
|
||||
.unwrap()
|
||||
.extension_exists(&developer, &extension_id)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn install_extension(id: String) -> Result<(), String> {
|
||||
let response = CLIENT
|
||||
.get(format!(
|
||||
"http://infini.tpddns.cn:27200/store/extension/{}/_download",
|
||||
id
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download extension: {}", e))?;
|
||||
|
||||
if response.status() == StatusCode::NOT_FOUND {
|
||||
return Err(format!("extension [{}] not found", id));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response bytes: {}", e))?;
|
||||
|
||||
let cursor = std::io::Cursor::new(bytes);
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?;
|
||||
|
||||
let mut plugin_json = archive.by_name("plugin.json").map_err(|e| e.to_string())?;
|
||||
let mut plugin_json_content = String::new();
|
||||
std::io::Read::read_to_string(&mut plugin_json, &mut plugin_json_content)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut extension: Json = serde_json::from_str(&plugin_json_content)
|
||||
.map_err(|e| format!("Failed to parse plugin.json: {}", e))?;
|
||||
|
||||
let mut_ref_to_developer_object: &mut Json = extension
|
||||
.as_object_mut()
|
||||
.expect("plugin.json should be an object")
|
||||
.get_mut("developer")
|
||||
.expect("plugin.json should contain field [developer]");
|
||||
let developer_id = mut_ref_to_developer_object
|
||||
.get("id")
|
||||
.expect("plugin.json should contain [developer.id]")
|
||||
.as_str()
|
||||
.expect("plugin.json field [developer.id] should be a string");
|
||||
*mut_ref_to_developer_object = Json::String(developer_id.into());
|
||||
|
||||
// Set IDs for sub-extensions (commands, quicklinks, scripts)
|
||||
let mut counter = 0;
|
||||
// Set IDs for commands
|
||||
// Helper function to set IDs for array fields
|
||||
fn set_ids_for_field(extension: &mut Json, field_name: &str, counter: &mut i32) {
|
||||
if let Some(field) = extension.as_object_mut().unwrap().get_mut(field_name) {
|
||||
if let Some(array) = field.as_array_mut() {
|
||||
for item in array {
|
||||
if let Some(item_obj) = item.as_object_mut() {
|
||||
if !item_obj.contains_key("id") {
|
||||
item_obj.insert("id".to_string(), Json::String(counter.to_string()));
|
||||
*counter += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set IDs for sub-extensions
|
||||
set_ids_for_field(&mut extension, "commands", &mut counter);
|
||||
set_ids_for_field(&mut extension, "quicklinks", &mut counter);
|
||||
set_ids_for_field(&mut extension, "scripts", &mut counter);
|
||||
|
||||
let mut extension: Extension = serde_json::from_value(extension).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"cannot parse plugin.json as struct Extension, error [{:?}]",
|
||||
e
|
||||
);
|
||||
});
|
||||
|
||||
drop(plugin_json);
|
||||
|
||||
let developer = extension.developer.clone().unwrap_or_default();
|
||||
let extension_id = extension.id.clone();
|
||||
|
||||
// Extract the zip file
|
||||
let extension_directory = {
|
||||
let mut path = THIRD_PARTY_EXTENSIONS_DIRECTORY.to_path_buf();
|
||||
path.push(developer);
|
||||
path.push(extension_id.as_str());
|
||||
path
|
||||
};
|
||||
|
||||
tokio::fs::create_dir_all(extension_directory.as_path())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Extract all files except plugin.json
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i).map_err(|e| e.to_string())?;
|
||||
let outpath = match file.enclosed_name() {
|
||||
Some(path) => extension_directory.join(path),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Skip the plugin.json file as we'll create it from the extension variable
|
||||
if file.name() == "plugin.json" {
|
||||
continue;
|
||||
}
|
||||
|
||||
if file.name().ends_with('/') {
|
||||
tokio::fs::create_dir_all(&outpath).await.map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
if let Some(p) = outpath.parent() {
|
||||
if !p.exists() {
|
||||
tokio::fs::create_dir_all(p).await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
let mut outfile = tokio::fs::File::create(&outpath).await.map_err(|e| e.to_string())?;
|
||||
let mut content = Vec::new();
|
||||
std::io::Read::read_to_end(&mut file, &mut content).map_err(|e| e.to_string())?;
|
||||
tokio::io::AsyncWriteExt::write_all(&mut outfile, &content).await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
// Create plugin.json from the extension variable
|
||||
let plugin_json_path = extension_directory.join(PLUGIN_JSON_FILE_NAME);
|
||||
let extension_json = serde_json::to_string_pretty(&extension).map_err(|e| e.to_string())?;
|
||||
tokio::fs::write(&plugin_json_path, extension_json).await.map_err(|e| e.to_string())?;
|
||||
|
||||
|
||||
// Turn it into an absolute path if it is a valid relative path because frontend code need this.
|
||||
canonicalize_relative_icon_path(&extension_directory, &mut extension)?;
|
||||
|
||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.get()
|
||||
.unwrap()
|
||||
.add_extension(extension)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn uninstall_extension(
|
||||
developer: String,
|
||||
extension_id: String,
|
||||
) -> Result<(), String> {
|
||||
let extension_dir = {
|
||||
let mut path = THIRD_PARTY_EXTENSIONS_DIRECTORY.join(developer.as_str());
|
||||
path.push(extension_id.as_str());
|
||||
|
||||
path
|
||||
};
|
||||
if !extension_dir.try_exists().map_err(|e| e.to_string())? {
|
||||
panic!(
|
||||
"we are uninstalling extension [{}/{}], but there is no such extension files on disk",
|
||||
developer, extension_id
|
||||
)
|
||||
}
|
||||
tokio::fs::remove_dir_all(extension_dir.as_path())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.get()
|
||||
.unwrap()
|
||||
.remove_extension(&developer, &extension_id)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -64,44 +64,44 @@ pub(crate) async fn list_third_party_extensions(
|
||||
|
||||
let mut extensions = Vec::new();
|
||||
|
||||
'author: loop {
|
||||
let opt_author_dir = extensions_dir_iter
|
||||
'developer: loop {
|
||||
let opt_developer_dir = extensions_dir_iter
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let Some(author_dir) = opt_author_dir else {
|
||||
let Some(developer_dir) = opt_developer_dir else {
|
||||
break;
|
||||
};
|
||||
let author_dir_file_type = author_dir.file_type().await.map_err(|e| e.to_string())?;
|
||||
if !author_dir_file_type.is_dir() {
|
||||
let developer_dir_file_type = developer_dir.file_type().await.map_err(|e| e.to_string())?;
|
||||
if !developer_dir_file_type.is_dir() {
|
||||
found_invalid_extensions = true;
|
||||
log::warn!(
|
||||
"file [{}] under the third party extension directory should be a directory, but it is not",
|
||||
author_dir.file_name().display()
|
||||
developer_dir.file_name().display()
|
||||
);
|
||||
|
||||
// Skip this file
|
||||
continue 'author;
|
||||
continue 'developer;
|
||||
}
|
||||
|
||||
let Ok(author) = author_dir.file_name().into_string() else {
|
||||
let Ok(developer) = developer_dir.file_name().into_string() else {
|
||||
found_invalid_extensions = true;
|
||||
|
||||
log::warn!(
|
||||
"author [{}] ID is not UTF-8 encoded",
|
||||
author_dir.file_name().display()
|
||||
"developer [{}] ID is not UTF-8 encoded",
|
||||
developer_dir.file_name().display()
|
||||
);
|
||||
|
||||
// Skip this file
|
||||
continue 'author;
|
||||
continue 'developer;
|
||||
};
|
||||
|
||||
let mut author_dir_iter = read_dir(&author_dir.path())
|
||||
let mut developer_dir_iter = read_dir(&developer_dir.path())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
'extension: loop {
|
||||
let opt_extension_dir = author_dir_iter
|
||||
let opt_extension_dir = developer_dir_iter
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -172,8 +172,8 @@ pub(crate) async fn list_third_party_extensions(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Set extension's author info manually.
|
||||
extension.author = Some(author.clone());
|
||||
// Set extension's developer info manually.
|
||||
extension.developer = Some(developer.clone());
|
||||
|
||||
extensions.push(extension);
|
||||
}
|
||||
@@ -250,25 +250,6 @@ fn validate_extension(
|
||||
|
||||
/// Checks that can be performed against an extension or a sub item.
|
||||
fn validate_extension_or_sub_item(extension: &Extension) -> bool {
|
||||
// Only
|
||||
//
|
||||
// 1. letters
|
||||
// 2. underscore
|
||||
// 3. numbers
|
||||
//
|
||||
// are allowed in the ID.
|
||||
if !extension
|
||||
.id
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphabetic() || c == '_')
|
||||
{
|
||||
log::warn!(
|
||||
"invalid extension [{}], [id] should contain only letters, numbers, or underscores",
|
||||
extension.id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If field `action` is Some, then it should be a Command
|
||||
if extension.action.is_some() && extension.r#type != ExtensionType::Command {
|
||||
log::warn!(
|
||||
@@ -341,15 +322,6 @@ fn validate_extension_or_sub_item(extension: &Extension) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
// The author field should not be set
|
||||
if extension.author.is_some() {
|
||||
log::warn!(
|
||||
"invalid extension [{}], unknown field [author]",
|
||||
extension.id,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
@@ -421,7 +393,7 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||
) -> Option<&'lock mut Extension> {
|
||||
let index = extensions_write_lock.iter().position(|ext| {
|
||||
ext.id == bundle_id.extension_id && ext.author.as_deref() == bundle_id.author
|
||||
ext.id == bundle_id.extension_id && ext.developer.as_deref() == bundle_id.developer
|
||||
})?;
|
||||
|
||||
let extension = extensions_write_lock
|
||||
@@ -659,7 +631,7 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Initialize the third-party extensions, which literally means
|
||||
/// Initialize the third-party extensions, which literally means
|
||||
/// enabling/activating the enabled extensions.
|
||||
pub(super) async fn init(&self) -> Result<(), String> {
|
||||
let extensions_read_lock = self.inner.extensions.read().await;
|
||||
@@ -794,7 +766,7 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
.iter()
|
||||
.find(|root_ext| {
|
||||
root_ext.id == bundle_id.extension_id
|
||||
&& root_ext.author.as_deref() == bundle_id.author
|
||||
&& root_ext.developer.as_deref() == bundle_id.developer
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
@@ -825,6 +797,51 @@ impl ThirdPartyExtensionsSearchSource {
|
||||
// 2. It is enabled
|
||||
Ok(root_extension.enabled && sub_extension.enabled)
|
||||
}
|
||||
|
||||
pub(crate) async fn extension_exists(&self, developer: &str, extension_id: &str) -> bool {
|
||||
let read_lock_guard = self.inner.extensions.read().await;
|
||||
read_lock_guard
|
||||
.iter()
|
||||
.any(|ext| ext.developer.as_deref() == Some(developer) && ext.id == extension_id)
|
||||
}
|
||||
|
||||
pub(crate) async fn add_extension(&self, extension: Extension) {
|
||||
assert!(
|
||||
extension.developer.is_some(),
|
||||
"loaded third party extension should have its developer set"
|
||||
);
|
||||
|
||||
let mut write_lock_guard = self.inner.extensions.write().await;
|
||||
if write_lock_guard
|
||||
.iter()
|
||||
.any(|ext| ext.developer == extension.developer && ext.id == extension.id)
|
||||
{
|
||||
panic!(
|
||||
"extension [{}/{}] already installed",
|
||||
extension
|
||||
.developer
|
||||
.as_ref()
|
||||
.expect("just checked it is Some"),
|
||||
extension.id
|
||||
);
|
||||
}
|
||||
write_lock_guard.push(extension);
|
||||
}
|
||||
|
||||
pub(crate) async fn remove_extension(&self, developer: &str, extension_id: &str) {
|
||||
let mut write_lock_guard = self.inner.extensions.write().await;
|
||||
let Some(index) = write_lock_guard
|
||||
.iter()
|
||||
.position(|ext| ext.developer.as_deref() == Some(developer) && ext.id == extension_id)
|
||||
else {
|
||||
panic!(
|
||||
"extension [{}/{}] not installed, but we are trying to remove it",
|
||||
developer, extension_id
|
||||
);
|
||||
};
|
||||
|
||||
write_lock_guard.remove(index);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) static THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE: OnceLock<ThirdPartyExtensionsSearchSource> =
|
||||
@@ -955,7 +972,7 @@ fn extension_to_hit(
|
||||
// Score based on title match
|
||||
// Title is considered more important, so it gets a higher weight.
|
||||
if let Some(title_score) =
|
||||
calculate_text_similarity(&query_lower, &extension.title.to_lowercase())
|
||||
calculate_text_similarity(&query_lower, &extension.name.to_lowercase())
|
||||
{
|
||||
total_score += title_score * 1.0; // Weight for title
|
||||
}
|
||||
@@ -980,7 +997,7 @@ fn extension_to_hit(
|
||||
|
||||
let document = Document {
|
||||
id: extension.id.clone(),
|
||||
title: Some(extension.title.clone()),
|
||||
title: Some(extension.name.clone()),
|
||||
icon: Some(extension.icon.clone()),
|
||||
on_opened: Some(on_opened),
|
||||
url: Some(url),
|
||||
|
||||
@@ -156,6 +156,9 @@ pub fn run() {
|
||||
extension::register_extension_hotkey,
|
||||
extension::unregister_extension_hotkey,
|
||||
extension::is_extension_enabled,
|
||||
extension::store::search_extension,
|
||||
extension::store::install_extension,
|
||||
extension::store::uninstall_extension,
|
||||
settings::set_allow_self_signature,
|
||||
settings::get_allow_self_signature,
|
||||
assistant::ask_ai,
|
||||
@@ -396,7 +399,8 @@ fn move_window_to_active_monitor<R: Runtime>(window: &WebviewWindow<R>) {
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_app_search_source<R: Runtime>(app_handle: AppHandle<R>) -> Result<(), String> {
|
||||
let (_found_invalid_extensions, extensions) = extension::list_extensions()
|
||||
// We want all the extensions here, so no filter condition specified.
|
||||
let (_found_invalid_extensions, extensions) = extension::list_extensions(None, None, false)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
extension::init_extensions(extensions).await?;
|
||||
|
||||
119
src/components/Common/DeleteDialog.tsx
Normal file
119
src/components/Common/DeleteDialog.tsx
Normal 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;
|
||||
118
src/components/Common/SearchEmpty.tsx
Normal file
118
src/components/Common/SearchEmpty.tsx
Normal 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;
|
||||
@@ -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" />
|
||||
|
||||
@@ -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]`}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
175
src/components/Search/ExtensionDetail.tsx
Normal file
175
src/components/Search/ExtensionDetail.tsx
Normal 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;
|
||||
338
src/components/Search/ExtensionStore.tsx
Normal file
338
src/components/Search/ExtensionStore.tsx
Normal 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;
|
||||
@@ -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", {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "删除"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user