mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-16 19:47:43 +01:00
refactor: use pizza_engine for app search (#346)
* refactor: use pizza_engine for app search * refactor: do not break the build when pizza_engine is unavailable
This commit is contained in:
50
package.json
50
package.json
@@ -19,36 +19,36 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.0.0",
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"@tauri-apps/api": "^2.4.0",
|
||||
"@headlessui/react": "^2.2.2",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-autostart": "~2.2.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.2.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.2.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.1",
|
||||
"@tauri-apps/plugin-global-shortcut": "~2.0.0",
|
||||
"@tauri-apps/plugin-http": "~2.0.2",
|
||||
"@tauri-apps/plugin-os": "^2.2.1",
|
||||
"@tauri-apps/plugin-process": "^2.2.0",
|
||||
"@tauri-apps/plugin-shell": "^2.2.0",
|
||||
"@tauri-apps/plugin-process": "^2.2.1",
|
||||
"@tauri-apps/plugin-shell": "^2.2.1",
|
||||
"@tauri-apps/plugin-updater": "github:infinilabs/tauri-plugin-updater#v2",
|
||||
"@tauri-apps/plugin-websocket": "~2.3.0",
|
||||
"@tauri-apps/plugin-window": "2.0.0-alpha.1",
|
||||
"@wavesurfer/react": "^1.0.9",
|
||||
"@wavesurfer/react": "^1.0.11",
|
||||
"ahooks": "^3.8.4",
|
||||
"axios": "^1.8.4",
|
||||
"axios": "^1.9.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.4.7",
|
||||
"dotenv": "^16.5.0",
|
||||
"filesize": "^10.1.6",
|
||||
"i18next": "^23.16.8",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"i18next-browser-languagedetector": "^8.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.461.0",
|
||||
"mermaid": "^11.5.0",
|
||||
"mermaid": "^11.6.0",
|
||||
"nanoid": "^5.1.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hotkeys-hook": "^4.6.1",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-hotkeys-hook": "^4.6.2",
|
||||
"react-i18next": "^15.5.1",
|
||||
"react-markdown": "^9.1.0",
|
||||
"react-router-dom": "^6.30.0",
|
||||
"react-window": "^1.8.11",
|
||||
@@ -58,25 +58,25 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"tauri-plugin-fs-pro-api": "^2.4.0",
|
||||
"tauri-plugin-macos-permissions-api": "^2.2.0",
|
||||
"tauri-plugin-screenshots-api": "^2.1.0",
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0",
|
||||
"tauri-plugin-screenshots-api": "^2.2.0",
|
||||
"tauri-plugin-windows-version-api": "^2.0.0",
|
||||
"use-debounce": "^10.0.4",
|
||||
"uuid": "^11.1.0",
|
||||
"wavesurfer.js": "^7.9.3",
|
||||
"zustand": "^5.0.3"
|
||||
"wavesurfer.js": "^7.9.5",
|
||||
"zustand": "^5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.4.0",
|
||||
"@tauri-apps/cli": "^2.5.0",
|
||||
"@types/dom-speech-recognition": "^0.0.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^22.13.11",
|
||||
"@types/react": "^18.3.19",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/node": "^22.15.17",
|
||||
"@types/react": "^18.3.21",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/react-katex": "^3.0.4",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"cross-env": "^7.0.3",
|
||||
"immer": "^10.1.1",
|
||||
@@ -85,8 +85,8 @@
|
||||
"sass": "^1.87.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsup": "^8.4.0",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^5.4.14"
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^5.4.19"
|
||||
}
|
||||
}
|
||||
|
||||
1735
pnpm-lock.yaml
generated
1735
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
1666
src-tauri/Cargo.lock
generated
1666
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -20,9 +20,15 @@ tauri-build = { version = "2", features = ["default"] }
|
||||
default = ["desktop"]
|
||||
desktop = []
|
||||
cargo-clippy = []
|
||||
# If enabled, dependency `pizza-engine` will be pulled in. Since it is still
|
||||
# private, you need access to the repo to enable this feature, or Coco-AI won't
|
||||
# compile.
|
||||
use_pizza_engine = ["dep:pizza-engine"]
|
||||
|
||||
[dependencies]
|
||||
pizza-common = { git = "https://github.com/infinilabs/pizza-common", branch = "main" }
|
||||
pizza-engine = { git = "https://github.com/infinilabs/pizza", features = ["query_string_parser", "persistence"], optional = true }
|
||||
|
||||
|
||||
tauri = { version = "2", features = ["protocol-asset", "macos-private-api", "tray-icon", "image-ico", "image-png", "unstable"] }
|
||||
tauri-plugin-shell = "2"
|
||||
@@ -42,7 +48,7 @@ tauri-plugin-drag = "2"
|
||||
tauri-plugin-macos-permissions = "2"
|
||||
tauri-plugin-fs-pro = "2"
|
||||
tauri-plugin-screenshots = "2"
|
||||
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "fb8f475993a2a774ce08d7a58f9f2ac264248a24" }
|
||||
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "1f62cd25651733bf8dc961c2382a39335a26ffe7" }
|
||||
|
||||
tokio-native-tls = "0.3" # For wss connections
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
@@ -61,7 +67,6 @@ hostname = "0.3"
|
||||
plist = "1.7"
|
||||
base64 = "0.13"
|
||||
walkdir = "2"
|
||||
fuzzy_prefix_search = "0.2"
|
||||
log = "0.4"
|
||||
|
||||
futures-util = "0.3.31"
|
||||
|
||||
2
src-tauri/rust-toolchain.toml
Normal file
2
src-tauri/rust-toolchain.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly-2024-10-29"
|
||||
@@ -55,39 +55,3 @@ pub struct Document {
|
||||
pub owner: Option<UserInfo>,
|
||||
pub last_updated_by: Option<EditorInfo>,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub fn new(
|
||||
source: Option<DataSourceReference>,
|
||||
id: String,
|
||||
category: String,
|
||||
name: String,
|
||||
url: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
created: None,
|
||||
updated: None,
|
||||
source,
|
||||
r#type: None,
|
||||
category: Some(category),
|
||||
subcategory: None,
|
||||
categories: None,
|
||||
rich_categories: None,
|
||||
title: Some(name),
|
||||
summary: None,
|
||||
lang: None,
|
||||
content: None,
|
||||
icon: None,
|
||||
thumbnail: None,
|
||||
cover: None,
|
||||
tags: None,
|
||||
url: Some(url),
|
||||
size: None,
|
||||
metadata: None,
|
||||
payload: None,
|
||||
owner: None,
|
||||
last_updated_by: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::server::servers::{load_or_insert_default_server, load_servers_token};
|
||||
use autostart::{change_autostart, enable_autostart};
|
||||
use lazy_static::lazy_static;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
use tauri::async_runtime::block_on;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::ActivationPolicy;
|
||||
@@ -30,6 +31,10 @@ lazy_static! {
|
||||
static ref PREVIOUS_MONITOR_NAME: Mutex<Option<String>> = Mutex::new(None);
|
||||
}
|
||||
|
||||
/// To allow us to access tauri's `AppHandle` when its context is inaccessible,
|
||||
/// store it globally. It will be set in `init()`.
|
||||
pub(crate) static GLOBAL_TAURI_APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
|
||||
|
||||
#[tauri::command]
|
||||
async fn change_window_height(handle: AppHandle, height: u32) {
|
||||
let window: WebviewWindow = handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
|
||||
@@ -134,13 +139,29 @@ pub fn run() {
|
||||
server::attachment::get_attachment,
|
||||
server::attachment::delete_attachment,
|
||||
server::transcription::transcription,
|
||||
local::application::get_default_search_paths,
|
||||
local::application::list_app_with_metadata_in,
|
||||
util::open,
|
||||
server::system_settings::get_system_settings,
|
||||
simulate_mouse_click
|
||||
simulate_mouse_click,
|
||||
local::get_disabled_local_query_sources,
|
||||
local::enable_local_query_source,
|
||||
local::disable_local_query_source,
|
||||
local::application::get_app_list,
|
||||
local::application::get_app_search_path,
|
||||
local::application::get_app_metadata,
|
||||
local::application::set_app_alias,
|
||||
local::application::register_app_hotkey,
|
||||
local::application::unregister_app_hotkey,
|
||||
local::application::disable_app_search,
|
||||
local::application::enable_app_search,
|
||||
local::application::add_app_search_path,
|
||||
local::application::remove_app_search_path,
|
||||
])
|
||||
.setup(|app| {
|
||||
let app_handle = app.handle().clone();
|
||||
GLOBAL_TAURI_APP_HANDLE
|
||||
.set(app_handle.clone())
|
||||
.expect("variable already initialized");
|
||||
|
||||
let registry = SearchSourceRegistry::default();
|
||||
|
||||
app.manage(registry); // Store registry in Tauri's app state
|
||||
@@ -234,19 +255,8 @@ pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
crate::server::servers::try_register_server_to_search_source(app_handle.clone(), &server)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn init_app_search_source<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||
let application_search =
|
||||
local::application::ApplicationSearchSource::new(app_handle.clone(), 1000f64).await?;
|
||||
let calculator_search = local::calculator::CalculatorSource::new(2000f64);
|
||||
|
||||
// Register the application search source
|
||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||
registry.register_source(application_search).await;
|
||||
registry.register_source(calculator_search).await;
|
||||
|
||||
Ok(())
|
||||
local::start_pizza_engine_runtime();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -402,7 +412,7 @@ fn open_settings(app: &tauri::AppHandle) {
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_app_search_source<R: Runtime>(app_handle: AppHandle<R>) -> Result<(), String> {
|
||||
init_app_search_source(&app_handle).await?;
|
||||
local::init_local_search_source(&app_handle).await?;
|
||||
let _ = server::connector::refresh_all_connectors(&app_handle).await;
|
||||
let _ = server::datasource::refresh_all_datasources(&app_handle).await;
|
||||
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
use crate::common::document::{DataSourceReference, Document};
|
||||
use crate::common::error::SearchError;
|
||||
use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::local::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use applications::App;
|
||||
use async_trait::async_trait;
|
||||
use fuzzy_prefix_search::Trie;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri_plugin_fs_pro::{icon, metadata, name, IconOptions};
|
||||
|
||||
const DATA_SOURCE_ID: &str = "Applications";
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_default_search_paths() -> Vec<String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
return vec![
|
||||
"/Applications".into(),
|
||||
"/System/Applications".into(),
|
||||
"/System/Library/CoreServices".into(),
|
||||
];
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let paths = applications::get_default_search_paths();
|
||||
let mut ret = Vec::with_capacity(paths.len());
|
||||
for search_path in paths {
|
||||
let path_string = search_path
|
||||
.into_os_string()
|
||||
.into_string()
|
||||
.expect("path should be UTF-8 encoded");
|
||||
|
||||
ret.push(path_string);
|
||||
}
|
||||
|
||||
ret
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to return `app`'s path.
|
||||
///
|
||||
/// * Windows: return the path to application's exe
|
||||
/// * macOS: return the path to the `.app` bundle
|
||||
/// * Linux: return the path to the `.desktop` file
|
||||
fn get_app_path(app: &App) -> PathBuf {
|
||||
if cfg!(target_os = "windows") {
|
||||
assert!(
|
||||
app.icon_path.is_some(),
|
||||
"we only accept Applications with icons"
|
||||
);
|
||||
app.app_path_exe
|
||||
.as_ref()
|
||||
.expect("icon is Some, exe path should be Some as well")
|
||||
.to_path_buf()
|
||||
} else {
|
||||
app.app_desktop_path.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to return `app`'s path.
|
||||
///
|
||||
/// * Windows/macOS: extract `app_path`'s file name and remove the file extension
|
||||
/// * Linux: return the name specified in `.desktop` file
|
||||
async fn get_app_name(app: &App) -> String {
|
||||
if cfg!(target_os = "linux") {
|
||||
app.name.clone()
|
||||
} else {
|
||||
let app_path = get_app_path(app);
|
||||
name(app_path.clone()).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to return an absolute path to `app`'s icon.
|
||||
///
|
||||
/// On macOS/Windows, we cache icons in our data directory using the `icon()` function.
|
||||
async fn get_app_icon_path<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
app: &App,
|
||||
) -> Result<PathBuf, String> {
|
||||
if cfg!(target_os = "linux") {
|
||||
let icon_path = app
|
||||
.icon_path
|
||||
.as_ref()
|
||||
.expect("We only accept applications with icons")
|
||||
.to_path_buf();
|
||||
|
||||
Ok(icon_path)
|
||||
} else {
|
||||
let app_path = get_app_path(app);
|
||||
let options = IconOptions {
|
||||
size: Some(256),
|
||||
save_path: None,
|
||||
};
|
||||
|
||||
icon(tauri_app_handle.clone(), app_path, Some(options))
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Return all the Apps found under `search_path`.
|
||||
///
|
||||
/// Note: apps with no icons will be filtered out.
|
||||
fn list_app_in(search_path: Vec<String>) -> Result<Vec<App>, String> {
|
||||
let search_path = search_path
|
||||
.into_iter()
|
||||
.map(PathBuf::from)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let apps = applications::get_all_apps(&search_path).map_err(|err| err.to_string())?;
|
||||
|
||||
Ok(apps
|
||||
.into_iter()
|
||||
.filter(|app| app.icon_path.is_some())
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppMetadata {
|
||||
name: String,
|
||||
r#where: PathBuf,
|
||||
size: u64,
|
||||
icon: PathBuf,
|
||||
created: u128,
|
||||
modified: u128,
|
||||
last_opened: u128,
|
||||
}
|
||||
|
||||
/// List apps that are in the `search_path`.
|
||||
///
|
||||
/// Different from `list_app_in()`, every app is JSON object containing its metadata, e.g.:
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "name": "Finder",
|
||||
/// "where": "/System/Library/CoreServices",
|
||||
/// "size": 49283072,
|
||||
/// "icon": "/xxx.png",
|
||||
/// "created": 1744625204,
|
||||
/// "modified": 1744625204,
|
||||
/// "lastOpened": 1744625250
|
||||
/// }
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn list_app_with_metadata_in<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
search_path: Vec<String>,
|
||||
) -> Result<Vec<AppMetadata>, String> {
|
||||
let apps = list_app_in(search_path)?;
|
||||
|
||||
let mut apps_with_meta = Vec::with_capacity(apps.len());
|
||||
|
||||
// name version where Type(hardcoded Application) Size Created Modify
|
||||
for app in apps.iter() {
|
||||
let app_path = get_app_path(app);
|
||||
let app_name = get_app_name(app).await;
|
||||
let app_path_where = {
|
||||
let mut app_path_clone = app_path.clone();
|
||||
let truncated = app_path_clone.pop();
|
||||
if !truncated {
|
||||
panic!("every app file should live somewhere");
|
||||
}
|
||||
|
||||
app_path_clone
|
||||
};
|
||||
let icon = get_app_icon_path(&app_handle, app).await?;
|
||||
|
||||
let raw_app_metadata = metadata(app_path.clone(), None).await?;
|
||||
|
||||
let app_metadata = AppMetadata {
|
||||
name: app_name,
|
||||
r#where: app_path_where,
|
||||
size: raw_app_metadata.size,
|
||||
icon,
|
||||
created: raw_app_metadata.created_at,
|
||||
modified: raw_app_metadata.modified_at,
|
||||
last_opened: raw_app_metadata.accessed_at,
|
||||
};
|
||||
|
||||
apps_with_meta.push(app_metadata);
|
||||
}
|
||||
|
||||
Ok(apps_with_meta)
|
||||
}
|
||||
|
||||
pub struct ApplicationSearchSource {
|
||||
base_score: f64,
|
||||
// app name -> app icon path
|
||||
icons: HashMap<String, PathBuf>,
|
||||
application_paths: Trie<PathBuf>,
|
||||
}
|
||||
|
||||
impl ApplicationSearchSource {
|
||||
pub async fn new<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
base_score: f64,
|
||||
) -> Result<Self, String> {
|
||||
let application_paths = Trie::new();
|
||||
let mut icons = HashMap::new();
|
||||
|
||||
let default_search_path = get_default_search_paths();
|
||||
let apps = list_app_in(default_search_path)?;
|
||||
|
||||
for app in &apps {
|
||||
let app_path = get_app_path(app);
|
||||
let app_name = get_app_name(app).await;
|
||||
let app_icon_path = get_app_icon_path(&app_handle, app).await?;
|
||||
|
||||
if app_name.is_empty() || app_name.eq("Coco-AI") {
|
||||
continue;
|
||||
}
|
||||
|
||||
application_paths.insert(&app_name, app_path);
|
||||
icons.insert(app_name, app_icon_path);
|
||||
}
|
||||
|
||||
Ok(ApplicationSearchSource {
|
||||
base_score,
|
||||
icons,
|
||||
application_paths,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SearchSource for ApplicationSearchSource {
|
||||
fn get_type(&self) -> QuerySource {
|
||||
QuerySource {
|
||||
r#type: LOCAL_QUERY_SOURCE_TYPE.into(),
|
||||
name: hostname::get()
|
||||
.unwrap_or("My Computer".into())
|
||||
.to_string_lossy()
|
||||
.into(),
|
||||
id: DATA_SOURCE_ID.into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
|
||||
let query_string = query
|
||||
.query_strings
|
||||
.get("query")
|
||||
.unwrap_or(&"".to_string())
|
||||
.to_lowercase();
|
||||
|
||||
if query_string.is_empty() {
|
||||
return Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let mut total_hits = 0;
|
||||
let mut hits = Vec::new();
|
||||
|
||||
let query_string_len = query_string.len();
|
||||
let mut results = self
|
||||
.application_paths
|
||||
.search_within_distance_scored(&query_string, query_string_len - 1);
|
||||
|
||||
// Check for NaN or extreme score values and handle them properly
|
||||
results.sort_by(|a, b| {
|
||||
// If either score is NaN, consider them equal (you can customize this logic as needed)
|
||||
if a.score.is_nan() || b.score.is_nan() {
|
||||
std::cmp::Ordering::Equal
|
||||
} else {
|
||||
// Otherwise, compare the scores as usual
|
||||
b.score
|
||||
.partial_cmp(&a.score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
}
|
||||
});
|
||||
|
||||
if !results.is_empty() {
|
||||
for result in results {
|
||||
let app_name = result.word;
|
||||
let app_path = result.data.first().unwrap().clone();
|
||||
let app_path_string = app_path.to_string_lossy().into_owned();
|
||||
|
||||
total_hits += 1;
|
||||
|
||||
let mut doc = Document::new(
|
||||
Some(DataSourceReference {
|
||||
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
||||
name: Some(DATA_SOURCE_ID.into()),
|
||||
id: Some(DATA_SOURCE_ID.into()),
|
||||
icon: None,
|
||||
}),
|
||||
app_path_string.clone(),
|
||||
"Application".to_string(),
|
||||
app_name.clone(),
|
||||
app_path_string.clone(),
|
||||
);
|
||||
|
||||
// Attach icon if available
|
||||
if let Some(icon_path) = self.icons.get(app_name.as_str()) {
|
||||
doc.icon = Some(icon_path.as_os_str().to_str().unwrap().to_string());
|
||||
}
|
||||
|
||||
hits.push((doc, self.base_score + result.score as f64));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits,
|
||||
total_hits,
|
||||
})
|
||||
}
|
||||
}
|
||||
38
src-tauri/src/local/application/mod.rs
Normal file
38
src-tauri/src/local/application/mod.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[cfg(feature = "use_pizza_engine")]
|
||||
mod with_feature;
|
||||
|
||||
#[cfg(not(feature = "use_pizza_engine"))]
|
||||
mod without_feature;
|
||||
|
||||
#[cfg(feature = "use_pizza_engine")]
|
||||
pub use with_feature::*;
|
||||
|
||||
#[cfg(not(feature = "use_pizza_engine"))]
|
||||
pub use without_feature::*;
|
||||
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppEntry {
|
||||
path: String,
|
||||
name: String,
|
||||
icon_path: String,
|
||||
alias: String,
|
||||
hotkey: String,
|
||||
is_disabled: bool,
|
||||
}
|
||||
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppMetadata {
|
||||
name: String,
|
||||
r#where: String,
|
||||
size: u64,
|
||||
icon: String,
|
||||
created: u128,
|
||||
modified: u128,
|
||||
last_opened: u128,
|
||||
}
|
||||
1109
src-tauri/src/local/application/with_feature.rs
Normal file
1109
src-tauri/src/local/application/with_feature.rs
Normal file
File diff suppressed because it is too large
Load Diff
121
src-tauri/src/local/application/without_feature.rs
Normal file
121
src-tauri/src/local/application/without_feature.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use crate::common::error::SearchError;
|
||||
use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::local::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use async_trait::async_trait;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use super::AppEntry;
|
||||
use super::AppMetadata;
|
||||
|
||||
pub(crate) const QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME: &str = "Applications";
|
||||
|
||||
pub struct ApplicationSearchSource;
|
||||
|
||||
impl ApplicationSearchSource {
|
||||
pub async fn init<R: Runtime>(_app_handle: AppHandle<R>) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SearchSource for ApplicationSearchSource {
|
||||
fn get_type(&self) -> QuerySource {
|
||||
QuerySource {
|
||||
r#type: LOCAL_QUERY_SOURCE_TYPE.into(),
|
||||
name: hostname::get()
|
||||
.unwrap_or("My Computer".into())
|
||||
.to_string_lossy()
|
||||
.into(),
|
||||
id: QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME.into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn search(&self, _query: SearchQuery) -> Result<QueryResponse, SearchError> {
|
||||
Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_app_alias(_app_path: String, _alias: String) -> Result<(), String> {
|
||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn register_app_hotkey<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
_app_path: String,
|
||||
_hotkey: String,
|
||||
) -> Result<(), String> {
|
||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn unregister_app_hotkey<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
_app_path: String,
|
||||
) -> Result<(), String> {
|
||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disable_app_search<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
_app_path: String,
|
||||
) -> Result<(), String> {
|
||||
// no-op
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn enable_app_search<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
_app_path: String,
|
||||
) -> Result<(), String> {
|
||||
// no-op
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_app_search_path<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
_search_path: String,
|
||||
) -> Result<(), String> {
|
||||
// no-op
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_app_search_path<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
_search_path: String,
|
||||
) -> Result<(), String> {
|
||||
// no-op
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_app_search_path<R: Runtime>(_tauri_app_handle: AppHandle<R>) -> Vec<String> {
|
||||
// Return an empty list
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_app_list<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
) -> Result<Vec<AppEntry>, String> {
|
||||
// Return an empty list
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_app_metadata<R: Runtime>(
|
||||
_tauri_app_handle: AppHandle<R>,
|
||||
_app_path: String,
|
||||
) -> Result<AppMetadata, String> {
|
||||
unreachable!("app list should be empty, there is no way this can be invoked")
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use num2words::Num2Words;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const DATA_SOURCE_ID: &str = "Calculator";
|
||||
pub(crate) const DATA_SOURCE_ID: &str = "Calculator";
|
||||
|
||||
pub struct CalculatorSource {
|
||||
base_score: f64,
|
||||
|
||||
@@ -2,4 +2,163 @@ pub mod application;
|
||||
pub mod calculator;
|
||||
pub mod file_system;
|
||||
|
||||
use std::any::Any;
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use serde_json::Value as Json;
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
|
||||
pub const TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE: &str = "local_query_source_enabled_state";
|
||||
|
||||
trait SearchSourceState {
|
||||
#[cfg_attr(not(feature = "use_pizza_engine"), allow(unused))]
|
||||
fn as_mut_any(&mut self) -> &mut dyn Any;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
trait Task: Send + Sync {
|
||||
fn search_source_id(&self) -> &'static str;
|
||||
|
||||
async fn exec(&mut self, state: &mut Option<Box<dyn SearchSourceState>>);
|
||||
}
|
||||
|
||||
static RUNTIME_TX: OnceLock<tokio::sync::mpsc::UnboundedSender<Box<dyn Task>>> = OnceLock::new();
|
||||
|
||||
pub(crate) fn start_pizza_engine_runtime() {
|
||||
std::thread::spawn(|| {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
|
||||
let main = async {
|
||||
let mut states: HashMap<String, Option<Box<dyn SearchSourceState>>> = HashMap::new();
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
RUNTIME_TX.set(tx).unwrap();
|
||||
|
||||
while let Some(mut task) = rx.recv().await {
|
||||
let opt_search_source_state = match states.entry(task.search_source_id().into()) {
|
||||
Entry::Occupied(o) => o.into_mut(),
|
||||
Entry::Vacant(v) => v.insert(None),
|
||||
};
|
||||
task.exec(opt_search_source_state).await;
|
||||
}
|
||||
};
|
||||
|
||||
rt.block_on(main);
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) async fn init_local_search_source<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
) -> Result<(), String> {
|
||||
let enabled_status_store = app_handle
|
||||
.store(TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if enabled_status_store.is_empty() {
|
||||
enabled_status_store.set(
|
||||
application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME,
|
||||
Json::Bool(true),
|
||||
);
|
||||
enabled_status_store.set(calculator::DATA_SOURCE_ID, Json::Bool(true));
|
||||
}
|
||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||
|
||||
application::ApplicationSearchSource::init(app_handle.clone()).await?;
|
||||
|
||||
for (id, enabled) in enabled_status_store.entries() {
|
||||
let enabled = match enabled {
|
||||
Json::Bool(b) => b,
|
||||
_ => unreachable!("enabled state should be stored as a boolean"),
|
||||
};
|
||||
|
||||
if enabled {
|
||||
if id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
||||
registry
|
||||
.register_source(application::ApplicationSearchSource)
|
||||
.await;
|
||||
}
|
||||
|
||||
if id == calculator::DATA_SOURCE_ID {
|
||||
let calculator_search = calculator::CalculatorSource::new(2000f64);
|
||||
registry.register_source(calculator_search).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_disabled_local_query_sources<R: Runtime>(app_handle: AppHandle<R>) -> Vec<String> {
|
||||
let enabled_status_store = app_handle
|
||||
.store(TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE)
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"tauri store [{}] should exist and be loaded, but that's not true due to error [{}]",
|
||||
TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE, e
|
||||
)
|
||||
});
|
||||
let mut disabled_local_query_sources = Vec::new();
|
||||
|
||||
for (id, enabled) in enabled_status_store.entries() {
|
||||
let enabled = match enabled {
|
||||
Json::Bool(b) => b,
|
||||
_ => unreachable!("enabled state should be stored as a boolean"),
|
||||
};
|
||||
|
||||
if !enabled {
|
||||
disabled_local_query_sources.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
disabled_local_query_sources
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn enable_local_query_source<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
query_source_id: String,
|
||||
) {
|
||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||
if query_source_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
||||
let application_search = application::ApplicationSearchSource;
|
||||
registry.register_source(application_search).await;
|
||||
}
|
||||
if query_source_id == calculator::DATA_SOURCE_ID {
|
||||
let calculator_search = calculator::CalculatorSource::new(2000f64);
|
||||
registry.register_source(calculator_search).await;
|
||||
}
|
||||
|
||||
let enabled_status_store = app_handle
|
||||
.store(TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE)
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"tauri store [{}] should exist and be loaded, but that's not true due to error [{}]",
|
||||
TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE, e
|
||||
)
|
||||
});
|
||||
enabled_status_store.set(query_source_id, Json::Bool(true));
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disable_local_query_source<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
query_source_id: String,
|
||||
) {
|
||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||
registry.remove_source(&query_source_id).await;
|
||||
|
||||
let enabled_status_store = app_handle
|
||||
.store(TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE)
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"tauri store [{}] should exist and be loaded, but that's not true due to error [{}]",
|
||||
TAURI_STORE_LOCAL_QUERY_SOURCE_ENABLED_STATE, e
|
||||
)
|
||||
});
|
||||
enabled_status_store.set(query_source_id, Json::Bool(false));
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ pub async fn query_coco_fusion<R: Runtime>(
|
||||
timeout(timeout_duration, async {
|
||||
query_source_clone.search(query).await
|
||||
})
|
||||
.await
|
||||
.await
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,9 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const sourceData = useSearchStore((state) => state.sourceData);
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
const queryTimeout = useConnectStore((state) => state.querySourceTimeout);
|
||||
const querySourceTimeout = useConnectStore((state) => {
|
||||
return state.querySourceTimeout;
|
||||
});
|
||||
|
||||
const [selectedItem, setSelectedItem] = useState<number | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -43,6 +45,11 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const [isKeyboardMode, setIsKeyboardMode] = useState(false);
|
||||
|
||||
const querySourceTimeoutRef = useRef(querySourceTimeout);
|
||||
useEffect(() => {
|
||||
querySourceTimeoutRef.current = querySourceTimeout;
|
||||
}, [querySourceTimeout]);
|
||||
|
||||
const { data, loading } = useInfiniteScroll(
|
||||
async (d) => {
|
||||
const from = d?.list?.length || 0;
|
||||
@@ -65,7 +72,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
from: from,
|
||||
size: PAGE_SIZE,
|
||||
queryStrings: queryStrings,
|
||||
queryTimeout,
|
||||
queryTimeout: querySourceTimeoutRef.current,
|
||||
});
|
||||
} else {
|
||||
let url = `/query/_search?query=${queryStrings.query}&datasource=${queryStrings.datasource}&from=${from}&size=${PAGE_SIZE}`;
|
||||
|
||||
@@ -45,7 +45,9 @@ function Search({
|
||||
setWindowAlwaysOnTop,
|
||||
}: SearchProps) {
|
||||
const sourceData = useSearchStore((state) => state.sourceData);
|
||||
const queryTimeout = useConnectStore((state) => state.querySourceTimeout);
|
||||
const querySourceTimeout = useConnectStore((state) => {
|
||||
return state.querySourceTimeout;
|
||||
});
|
||||
|
||||
const [IsError, setIsError] = useState<any[]>([]);
|
||||
const [suggests, setSuggests] = useState<any[]>([]);
|
||||
@@ -54,6 +56,11 @@ function Search({
|
||||
|
||||
const mainWindowRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const querySourceTimeoutRef = useRef(querySourceTimeout);
|
||||
useEffect(() => {
|
||||
querySourceTimeoutRef.current = querySourceTimeout;
|
||||
}, [querySourceTimeout]);
|
||||
|
||||
const getSuggest = useCallback(
|
||||
async (searchInput: string) => {
|
||||
if (!searchInput) return;
|
||||
@@ -65,7 +72,7 @@ function Search({
|
||||
from: 0,
|
||||
size: 10,
|
||||
queryStrings: { query: searchInput },
|
||||
queryTimeout: queryTimeout,
|
||||
queryTimeout: querySourceTimeoutRef.current,
|
||||
});
|
||||
if (response && typeof response === "object" && "failed" in response) {
|
||||
const failedResult = response as any;
|
||||
|
||||
@@ -110,25 +110,23 @@ function SearchChat({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const init = async () => {
|
||||
if (!mounted) return;
|
||||
await initializeListeners();
|
||||
await initializeListeners_auth();
|
||||
await platformAdapter.invokeBackend("get_app_search_source");
|
||||
if (theme && mounted) {
|
||||
setTheme(theme);
|
||||
}
|
||||
|
||||
platformAdapter.emitEvent("search-source-loaded");
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!theme) return;
|
||||
|
||||
setTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
const chatAIRef = useRef<ChatAIRef>(null);
|
||||
|
||||
const changeMode = useCallback(async (value: boolean) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ChevronRight } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import SettingsToggle from "@/components/Settings/SettingsToggle";
|
||||
import { ExtensionsContext, Plugin } from "../..";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
interface AccordionProps extends Plugin {}
|
||||
|
||||
@@ -14,11 +15,10 @@ const Accordion: FC<AccordionProps> = (props) => {
|
||||
type = "Extension",
|
||||
alias = "-",
|
||||
hotKey = "-",
|
||||
enabled = true,
|
||||
content,
|
||||
} = props;
|
||||
const { activeId, setActiveId } = useContext(ExtensionsContext);
|
||||
|
||||
const { activeId, setActiveId, disabledExtensions, setDisabledExtensions } =
|
||||
useContext(ExtensionsContext);
|
||||
const [expand, setExpand] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -56,12 +56,37 @@ const Accordion: FC<AccordionProps> = (props) => {
|
||||
<div className="flex-1">{type}</div>
|
||||
<div className="flex-1">{alias}</div>
|
||||
<div className="flex-1">{hotKey}</div>
|
||||
<div className="flex-1 flex items-center justify-end">
|
||||
<div
|
||||
className="flex-1 flex items-center justify-end"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<SettingsToggle
|
||||
label=""
|
||||
checked={enabled}
|
||||
checked={!disabledExtensions.includes(id)}
|
||||
className="scale-75"
|
||||
onChange={() => {}}
|
||||
onChange={(value) => {
|
||||
console.log("value", value);
|
||||
|
||||
if (value) {
|
||||
setDisabledExtensions(
|
||||
disabledExtensions.filter((extensionId) => {
|
||||
return extensionId !== id;
|
||||
})
|
||||
);
|
||||
|
||||
platformAdapter.invokeBackend("enable_local_query_source", {
|
||||
querySourceId: id,
|
||||
});
|
||||
} else {
|
||||
setDisabledExtensions(disabledExtensions.concat(id));
|
||||
|
||||
platformAdapter.invokeBackend("disable_local_query_source", {
|
||||
querySourceId: id,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,36 +1,102 @@
|
||||
import SettingsToggle from "@/components/Settings/SettingsToggle";
|
||||
import { useApplicationsStore } from "@/stores/applicationsStore";
|
||||
import { Application, useApplicationsStore } from "@/stores/applicationsStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useContext } from "react";
|
||||
import { ExtensionsContext } from "../../..";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDebounceFn } from "ahooks";
|
||||
import SettingsInput from "@/components/Settings/SettingsInput";
|
||||
import Shortcut from "../../Shortcut";
|
||||
|
||||
const Applications = () => {
|
||||
const { t } = useTranslation();
|
||||
const { activeId, setActiveId } = useContext(ExtensionsContext);
|
||||
|
||||
const allApps = useApplicationsStore((state) => state.allApps);
|
||||
const disabledApps = useApplicationsStore((state) => state.disabledApps);
|
||||
const setDisabledApps = useApplicationsStore((state) => {
|
||||
return state.setDisabledApps;
|
||||
});
|
||||
const setAllApps = useApplicationsStore((state) => state.setAllApps);
|
||||
|
||||
const handleDisable = (app: Application) => {
|
||||
const { path, isDisabled } = app;
|
||||
|
||||
const nextApps = allApps.map((item) => {
|
||||
if (item.path !== path) return item;
|
||||
|
||||
return { ...item, isDisabled: !isDisabled };
|
||||
});
|
||||
setAllApps(nextApps);
|
||||
|
||||
if (isDisabled) {
|
||||
platformAdapter.invokeBackend("enable_app_search", {
|
||||
appPath: path,
|
||||
});
|
||||
} else {
|
||||
platformAdapter.invokeBackend("disable_app_search", {
|
||||
appPath: path,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const { run: handleAlias } = useDebounceFn(
|
||||
(app: Application, alias: string) => {
|
||||
const { path } = app;
|
||||
|
||||
platformAdapter.invokeBackend("set_app_alias", {
|
||||
appPath: path,
|
||||
alias,
|
||||
});
|
||||
|
||||
const nextApps = allApps.map((item) => {
|
||||
if (item.path !== path) return item;
|
||||
|
||||
return { ...item, alias };
|
||||
});
|
||||
|
||||
setAllApps(nextApps);
|
||||
}
|
||||
);
|
||||
|
||||
const handleHotkey = (app: Application, hotkey: string) => {
|
||||
const { path } = app;
|
||||
|
||||
if (hotkey) {
|
||||
platformAdapter.invokeBackend("register_app_hotkey", {
|
||||
appPath: path,
|
||||
hotkey,
|
||||
});
|
||||
} else {
|
||||
platformAdapter.invokeBackend("unregister_app_hotkey", {
|
||||
appPath: path,
|
||||
});
|
||||
}
|
||||
|
||||
const nextApps = allApps.map((item) => {
|
||||
if (item.path !== path) return item;
|
||||
|
||||
return { ...item, hotkey };
|
||||
});
|
||||
|
||||
setAllApps(nextApps);
|
||||
};
|
||||
|
||||
return allApps.map((app) => {
|
||||
const { name, icon } = app;
|
||||
const { name, path, iconPath, isDisabled, alias, hotkey } = app;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
key={path}
|
||||
className={clsx("flex items-center h-8 -mx-2 pl-10 pr-2 rounded-md", {
|
||||
"bg-[#f0f6fe] dark:bg-gray-700": name === activeId,
|
||||
"bg-[#f0f6fe] dark:bg-gray-700": path === activeId,
|
||||
})}
|
||||
onClick={() => {
|
||||
setActiveId(name);
|
||||
setActiveId(path);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1 w-[180px] pr-2 overflow-hidden">
|
||||
<img src={platformAdapter.convertFileSrc(icon)} className="size-5" />
|
||||
<img
|
||||
src={platformAdapter.convertFileSrc(iconPath)}
|
||||
className="size-5"
|
||||
/>
|
||||
|
||||
<span className="text-sm truncate">{name}</span>
|
||||
</div>
|
||||
@@ -39,24 +105,43 @@ const Applications = () => {
|
||||
<div className="flex-1">
|
||||
{t("settings.extensions.application.title")}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{t("settings.extensions.application.hits.addAlias")}
|
||||
<div
|
||||
className="flex-1"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<SettingsInput
|
||||
defaultValue={alias}
|
||||
placeholder={t("settings.extensions.application.hits.addAlias")}
|
||||
className="!w-[90%] !h-6 border-transparent rounded-[4px]"
|
||||
onChange={(event) => {
|
||||
handleAlias(app, event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{t("settings.extensions.application.hits.recordHotkey")}
|
||||
<div
|
||||
className="flex-1"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Shortcut
|
||||
value={hotkey}
|
||||
placeholder={t(
|
||||
"settings.extensions.application.hits.recordHotkey"
|
||||
)}
|
||||
onChange={(value) => {
|
||||
handleHotkey(app, value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-end">
|
||||
<SettingsToggle
|
||||
label=""
|
||||
checked={!disabledApps.includes(name)}
|
||||
checked={!isDisabled}
|
||||
className="scale-75"
|
||||
onChange={() => {
|
||||
if (disabledApps.includes(name)) {
|
||||
setDisabledApps(disabledApps.filter((app) => app !== name));
|
||||
} else {
|
||||
setDisabledApps([...disabledApps, name]);
|
||||
}
|
||||
}}
|
||||
onChange={() => handleDisable(app)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,47 +1,66 @@
|
||||
import { FC } from "react";
|
||||
import { Application } from "@/stores/applicationsStore";
|
||||
import { useContext, useMemo, useState } from "react";
|
||||
import { ApplicationMetadata } from "@/stores/applicationsStore";
|
||||
import { filesize } from "filesize";
|
||||
import dayjs from "dayjs";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "ahooks";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { ExtensionsContext } from "../../..";
|
||||
|
||||
interface AppProps {
|
||||
current: Application;
|
||||
}
|
||||
|
||||
const App: FC<AppProps> = (props) => {
|
||||
const { name, where, size, created, modified, lastOpened } = props.current;
|
||||
const App = () => {
|
||||
const { t } = useTranslation();
|
||||
const { activeId } = useContext(ExtensionsContext);
|
||||
|
||||
const metadata = [
|
||||
{
|
||||
label: t("settings.extensions.application.details.name"),
|
||||
value: name,
|
||||
},
|
||||
{
|
||||
label: t("settings.extensions.application.details.where"),
|
||||
value: where,
|
||||
},
|
||||
{
|
||||
label: t("settings.extensions.application.details.type"),
|
||||
value: t("settings.extensions.application.details.typeValue"),
|
||||
},
|
||||
{
|
||||
label: t("settings.extensions.application.details.size"),
|
||||
value: filesize(size, { standard: "jedec", spacer: "" }),
|
||||
},
|
||||
{
|
||||
label: t("settings.extensions.application.details.created"),
|
||||
value: dayjs(created).format("YYYY/MM/DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
label: t("settings.extensions.application.details.modified"),
|
||||
value: dayjs(modified).format("YYYY/MM/DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
label: t("settings.extensions.application.details.lastOpened"),
|
||||
value: dayjs(lastOpened).format("YYYY/MM/DD HH:mm:ss"),
|
||||
},
|
||||
];
|
||||
const [appMetadata, setAppMetadata] = useState<ApplicationMetadata>();
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
const appMetadata =
|
||||
await platformAdapter.invokeBackend<ApplicationMetadata>(
|
||||
"get_app_metadata",
|
||||
{
|
||||
appPath: activeId,
|
||||
}
|
||||
);
|
||||
|
||||
setAppMetadata(appMetadata);
|
||||
}, [activeId]);
|
||||
|
||||
const metadata = useMemo(() => {
|
||||
if (!appMetadata) return [];
|
||||
|
||||
const { name, where, size, created, modified, lastOpened } = appMetadata;
|
||||
|
||||
return [
|
||||
{
|
||||
label: t("settings.extensions.application.details.name"),
|
||||
value: name,
|
||||
},
|
||||
{
|
||||
label: t("settings.extensions.application.details.where"),
|
||||
value: where,
|
||||
},
|
||||
{
|
||||
label: t("settings.extensions.application.details.type"),
|
||||
value: t("settings.extensions.application.details.typeValue"),
|
||||
},
|
||||
{
|
||||
label: t("settings.extensions.application.details.size"),
|
||||
value: filesize(size, { standard: "jedec", spacer: "" }),
|
||||
},
|
||||
{
|
||||
label: t("settings.extensions.application.details.created"),
|
||||
value: dayjs(created).format("YYYY/MM/DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
label: t("settings.extensions.application.details.modified"),
|
||||
value: dayjs(modified).format("YYYY/MM/DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
label: t("settings.extensions.application.details.lastOpened"),
|
||||
value: dayjs(lastOpened).format("YYYY/MM/DD HH:mm:ss"),
|
||||
},
|
||||
];
|
||||
}, [appMetadata]);
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-2 p-0">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useApplicationsStore } from "@/stores/applicationsStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { Button } from "@headlessui/react";
|
||||
import { castArray, union } from "lodash-es";
|
||||
import { castArray } from "lodash-es";
|
||||
import { Folder, SquareArrowOutUpRight, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -9,8 +10,9 @@ const Applications = () => {
|
||||
const { t } = useTranslation();
|
||||
const searchPaths = useApplicationsStore((state) => state.searchPaths);
|
||||
const setSearchPaths = useApplicationsStore((state) => state.setSearchPaths);
|
||||
const addError = useAppStore((state) => state.addError);
|
||||
|
||||
const selectDirectory = async () => {
|
||||
const handleAdd = async () => {
|
||||
const selected = await platformAdapter.openFileDialog({
|
||||
directory: true,
|
||||
multiple: true,
|
||||
@@ -18,7 +20,49 @@ const Applications = () => {
|
||||
|
||||
if (!selected) return;
|
||||
|
||||
setSearchPaths(union(searchPaths, castArray(selected)));
|
||||
const selectedPaths = castArray(selected).filter((selectedPath) => {
|
||||
if (searchPaths.includes(selectedPath)) {
|
||||
addError(
|
||||
t("settings.extensions.application.hits.pathDuplication", {
|
||||
replace: [selectedPath],
|
||||
})
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const isChildPath = searchPaths.some((item) => {
|
||||
return selectedPath.startsWith(item);
|
||||
});
|
||||
|
||||
if (isChildPath) {
|
||||
addError(
|
||||
t("settings.extensions.application.hits.pathIncluded", {
|
||||
replace: [selectedPath],
|
||||
})
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
setSearchPaths(searchPaths.concat(selectedPaths));
|
||||
|
||||
for await (const path of selectedPaths) {
|
||||
await platformAdapter.invokeBackend("add_app_search_path", {
|
||||
searchPath: path,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (path: string) => {
|
||||
setSearchPaths(searchPaths.filter((item) => item !== path));
|
||||
|
||||
platformAdapter.invokeBackend("remove_app_search_path", {
|
||||
searchPath: path,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -35,7 +79,7 @@ const Applications = () => {
|
||||
|
||||
<Button
|
||||
className="w-full h-8 my-4 text-[#0087FF] border border-[#EEF0F3] hover:border-[#0087FF] dark:border-gray-700 rounded-md transition"
|
||||
onClick={selectDirectory}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
{t("settings.extensions.application.button.addDirectories")}
|
||||
</Button>
|
||||
@@ -60,9 +104,7 @@ const Applications = () => {
|
||||
|
||||
<X
|
||||
className="size-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
setSearchPaths(searchPaths.filter((path) => path !== item));
|
||||
}}
|
||||
onClick={() => handleRemove(item)}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
143
src/components/Settings/Extensions/components/Shortcut/index.tsx
Normal file
143
src/components/Settings/Extensions/components/Shortcut/index.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { find, isEmpty, map, remove, some, split } from "lodash-es";
|
||||
import { useRef, type FC, type KeyboardEvent, type MouseEvent } from "react";
|
||||
import { type Key, keys, modifierKeys, standardKeys } from "./keyboard";
|
||||
import { CircleX } from "lucide-react";
|
||||
import { useFocusWithin, useHover, useReactive } from "ahooks";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface ShortcutProps {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
isSystem?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
value: Key[];
|
||||
}
|
||||
|
||||
const Shortcut: FC<ShortcutProps> = (props) => {
|
||||
const { value = "", placeholder, isSystem = true, onChange } = props;
|
||||
|
||||
const separator = isSystem ? "+" : ".";
|
||||
const keyFiled = isSystem ? "tauriKey" : "hookKey";
|
||||
|
||||
const parseValue = () => {
|
||||
if (!value) return [];
|
||||
|
||||
return split(value, separator).map((key) => {
|
||||
return find(keys, { [keyFiled]: key })!;
|
||||
});
|
||||
};
|
||||
|
||||
const state = useReactive<State>({
|
||||
value: parseValue(),
|
||||
});
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isHovering = useHover(containerRef);
|
||||
|
||||
const isFocusing = useFocusWithin(containerRef, {
|
||||
onFocus: () => {
|
||||
state.value = [];
|
||||
},
|
||||
onBlur: () => {
|
||||
if (!isValidShortcut()) {
|
||||
state.value = parseValue();
|
||||
}
|
||||
|
||||
handleChange();
|
||||
},
|
||||
});
|
||||
|
||||
const isValidShortcut = () => {
|
||||
if (state.value?.[0]?.eventKey?.startsWith("F")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasModifierKey = some(state.value, ({ eventKey }) => {
|
||||
return some(modifierKeys, { eventKey });
|
||||
});
|
||||
const hasStandardKey = some(state.value, ({ eventKey }) => {
|
||||
return some(standardKeys, { eventKey });
|
||||
});
|
||||
|
||||
return hasModifierKey && hasStandardKey;
|
||||
};
|
||||
|
||||
const getEventKey = (event: KeyboardEvent) => {
|
||||
let { key, code } = event;
|
||||
|
||||
key = key.replace("Meta", "Command");
|
||||
|
||||
const isModifierKey = some(modifierKeys, { eventKey: key });
|
||||
|
||||
return isModifierKey ? key : code;
|
||||
};
|
||||
|
||||
const handleChange = () => {
|
||||
const nextValue = map(state.value, keyFiled).join(separator);
|
||||
|
||||
onChange?.(nextValue);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const eventKey = getEventKey(event);
|
||||
|
||||
const matched = find(keys, { eventKey });
|
||||
const isInvalid = !matched;
|
||||
const isDuplicate = some(state.value, { eventKey });
|
||||
|
||||
if (isInvalid || isDuplicate) return;
|
||||
|
||||
state.value.push(matched);
|
||||
|
||||
if (isValidShortcut()) {
|
||||
containerRef.current?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
remove(state.value, { eventKey: getEventKey(event) });
|
||||
};
|
||||
|
||||
const handleClear = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
state.value = [];
|
||||
|
||||
handleChange();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={0}
|
||||
className="relative flex items-center h-6 px-2 rounded-[4px] border border-transparent hover:border-[#0072FF] focus:border-[#0072FF] transition"
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
>
|
||||
{isEmpty(state.value) ? (
|
||||
<div className="whitespace-nowrap">{placeholder}</div>
|
||||
) : (
|
||||
<div className="font-bold text-primary">
|
||||
{map(state.value, "symbol").join(" ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CircleX
|
||||
size={16}
|
||||
className={clsx(
|
||||
"absolute right-2 hover:text-[#0072FF] cursor-pointer transition",
|
||||
{
|
||||
hidden: isFocusing || !isHovering || isEmpty(state.value),
|
||||
}
|
||||
)}
|
||||
onMouseDown={handleClear}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Shortcut;
|
||||
@@ -0,0 +1,314 @@
|
||||
import { isMac } from "@/utils/platform";
|
||||
import { defaults } from "lodash-es";
|
||||
|
||||
export interface Key {
|
||||
eventKey: string;
|
||||
hookKey?: string;
|
||||
tauriKey?: string;
|
||||
symbol?: string;
|
||||
}
|
||||
|
||||
export const modifierKeys: Key[] = [
|
||||
{
|
||||
eventKey: "Shift",
|
||||
symbol: isMac ? "⇧" : "Shift",
|
||||
},
|
||||
{
|
||||
eventKey: "Control",
|
||||
hookKey: "ctrl",
|
||||
symbol: isMac ? "⌃" : "Ctrl",
|
||||
},
|
||||
{
|
||||
eventKey: "Alt",
|
||||
symbol: isMac ? "⌥" : "Alt",
|
||||
},
|
||||
{
|
||||
eventKey: "Command",
|
||||
hookKey: "meta",
|
||||
symbol: isMac ? "⌘" : "Super",
|
||||
},
|
||||
].map((item) => {
|
||||
const { eventKey } = item;
|
||||
|
||||
defaults<Key, Partial<Key>>(item, {
|
||||
hookKey: eventKey.toLowerCase(),
|
||||
tauriKey: eventKey,
|
||||
});
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
export const standardKeys: Key[] = [
|
||||
// 第一排
|
||||
{
|
||||
eventKey: "Escape",
|
||||
hookKey: "esc",
|
||||
symbol: isMac ? "⎋" : "Esc",
|
||||
},
|
||||
{
|
||||
eventKey: "F1",
|
||||
},
|
||||
{
|
||||
eventKey: "F2",
|
||||
},
|
||||
{
|
||||
eventKey: "F3",
|
||||
},
|
||||
{
|
||||
eventKey: "F4",
|
||||
},
|
||||
{
|
||||
eventKey: "F5",
|
||||
},
|
||||
{
|
||||
eventKey: "F6",
|
||||
},
|
||||
{
|
||||
eventKey: "F7",
|
||||
},
|
||||
{
|
||||
eventKey: "F8",
|
||||
},
|
||||
{
|
||||
eventKey: "F9",
|
||||
},
|
||||
{
|
||||
eventKey: "F10",
|
||||
},
|
||||
{
|
||||
eventKey: "F11",
|
||||
},
|
||||
{
|
||||
eventKey: "F12",
|
||||
}, // 第二排
|
||||
{
|
||||
eventKey: "Backquote",
|
||||
hookKey: "graveaccent",
|
||||
symbol: "`",
|
||||
},
|
||||
{
|
||||
eventKey: "Digit1",
|
||||
},
|
||||
{
|
||||
eventKey: "Digit2",
|
||||
},
|
||||
{
|
||||
eventKey: "Digit3",
|
||||
},
|
||||
{
|
||||
eventKey: "Digit4",
|
||||
},
|
||||
{
|
||||
eventKey: "Digit5",
|
||||
},
|
||||
{
|
||||
eventKey: "Digit6",
|
||||
},
|
||||
{
|
||||
eventKey: "Digit7",
|
||||
},
|
||||
{
|
||||
eventKey: "Digit8",
|
||||
},
|
||||
{
|
||||
eventKey: "Digit9",
|
||||
},
|
||||
{
|
||||
eventKey: "Digit0",
|
||||
},
|
||||
{
|
||||
eventKey: "Minus",
|
||||
hookKey: "dash",
|
||||
tauriKey: "-",
|
||||
symbol: "-",
|
||||
},
|
||||
{
|
||||
eventKey: "Equal",
|
||||
hookKey: "equalsign",
|
||||
tauriKey: "=",
|
||||
symbol: "=",
|
||||
},
|
||||
{
|
||||
eventKey: "Backspace",
|
||||
symbol: isMac ? "⌫" : void 0,
|
||||
},
|
||||
// 第三排
|
||||
{
|
||||
eventKey: "Tab",
|
||||
symbol: isMac ? "⇥" : void 0,
|
||||
},
|
||||
{
|
||||
eventKey: "KeyQ",
|
||||
},
|
||||
{
|
||||
eventKey: "KeyW",
|
||||
},
|
||||
{
|
||||
eventKey: "KeyE",
|
||||
},
|
||||
{
|
||||
eventKey: "KeyR",
|
||||
},
|
||||
{
|
||||
eventKey: "KeyT",
|
||||
},
|
||||
{
|
||||
eventKey: "KeyY",
|
||||
},
|
||||
{
|
||||
eventKey: "KeyU",
|
||||
},
|
||||
{
|
||||
eventKey: "KeyI",
|
||||
},
|
||||
{
|
||||
eventKey: "KeyO",
|
||||
},
|
||||
{
|
||||
eventKey: "KeyP",
|
||||
},
|
||||
{
|
||||
eventKey: "BracketLeft",
|
||||
hookKey: "openbracket",
|
||||
symbol: "[",
|
||||
},
|
||||
{
|
||||
eventKey: "BracketRight",
|
||||
hookKey: "closebracket",
|
||||
symbol: "]",
|
||||
},
|
||||
{
|
||||
eventKey: "Backslash",
|
||||
symbol: "\\",
|
||||
},
|
||||
// 第四排
|
||||
{
|
||||
eventKey: "KeyA",
|
||||
},
|
||||
{
|
||||
eventKey: "KeyS",
|
||||
},
|
||||
{
|
||||
eventKey: "KeyD",
|
||||
},
|
||||
{
|
||||
eventKey: "KeyF",
|
||||
},
|
||||
{
|
||||
eventKey: "KeyG",
|
||||
},
|
||||
{
|
||||
eventKey: "KeyH",
|
||||
},
|
||||
{
|
||||
eventKey: "KeyJ",
|
||||
},
|
||||
{
|
||||
eventKey: "KeyK",
|
||||
},
|
||||
{
|
||||
eventKey: "KeyL",
|
||||
},
|
||||
{
|
||||
eventKey: "Semicolon",
|
||||
symbol: ";",
|
||||
},
|
||||
{
|
||||
eventKey: "Quote",
|
||||
hookKey: "singlequote",
|
||||
symbol: "'",
|
||||
},
|
||||
{
|
||||
eventKey: "Enter",
|
||||
symbol: isMac ? "↩︎" : void 0,
|
||||
},
|
||||
// 第五排
|
||||
{
|
||||
eventKey: "KeyZ",
|
||||
},
|
||||
{
|
||||
eventKey: "KeyX",
|
||||
},
|
||||
{
|
||||
eventKey: "KeyC",
|
||||
},
|
||||
{
|
||||
eventKey: "KeyV",
|
||||
},
|
||||
{
|
||||
eventKey: "KeyB",
|
||||
},
|
||||
{
|
||||
eventKey: "KeyN",
|
||||
},
|
||||
{
|
||||
eventKey: "KeyM",
|
||||
},
|
||||
{
|
||||
eventKey: "Comma",
|
||||
symbol: ",",
|
||||
},
|
||||
{
|
||||
eventKey: "Period",
|
||||
symbol: ".",
|
||||
},
|
||||
{
|
||||
eventKey: "Slash",
|
||||
hookKey: "forwardslash",
|
||||
symbol: "/",
|
||||
},
|
||||
// 第六排
|
||||
{
|
||||
eventKey: "Space",
|
||||
symbol: isMac ? "␣" : void 0,
|
||||
},
|
||||
// 方向键
|
||||
{
|
||||
eventKey: "ArrowUp",
|
||||
hookKey: "uparrow",
|
||||
symbol: "↑",
|
||||
},
|
||||
{
|
||||
eventKey: "ArrowDown",
|
||||
hookKey: "downarrow",
|
||||
symbol: "↓",
|
||||
},
|
||||
{
|
||||
eventKey: "ArrowLeft",
|
||||
hookKey: "leftarrow",
|
||||
symbol: "←",
|
||||
},
|
||||
{
|
||||
eventKey: "ArrowRight",
|
||||
hookKey: "rightarrow",
|
||||
symbol: "→",
|
||||
},
|
||||
].map((item) => {
|
||||
const { eventKey } = item;
|
||||
|
||||
defaults<Key, Partial<Key>>(item, {
|
||||
hookKey: eventKey.toLowerCase(),
|
||||
symbol: eventKey,
|
||||
tauriKey: eventKey,
|
||||
});
|
||||
|
||||
if (eventKey.startsWith("Digit") || eventKey.startsWith("Key")) {
|
||||
item.tauriKey = item.symbol = eventKey.slice(-1);
|
||||
|
||||
item.hookKey = item.tauriKey.toLowerCase();
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
export const keys = modifierKeys.concat(standardKeys);
|
||||
|
||||
export const getKeySymbol = (key: string) => {
|
||||
const fields = ["tauriKey", "hookKey"] as const;
|
||||
|
||||
const matched = keys.find((entry) => {
|
||||
return fields.some((field) => entry[field] === key);
|
||||
});
|
||||
|
||||
return matched?.symbol ?? key;
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Folder } from "lucide-react";
|
||||
import { Calculator, Folder } from "lucide-react";
|
||||
import { noop } from "lodash-es";
|
||||
|
||||
import Accordion from "./components/Accordion";
|
||||
@@ -14,6 +14,9 @@ import ApplicationsDetail from "./components/Details/Applications";
|
||||
import { useApplicationsStore } from "@/stores/applicationsStore";
|
||||
import Application from "./components/Details/Application";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMount } from "ahooks";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useExtensionStore } from "@/stores/extension";
|
||||
|
||||
export interface Plugin {
|
||||
id: string;
|
||||
@@ -30,14 +33,34 @@ export interface Plugin {
|
||||
interface ExtensionsContextType {
|
||||
activeId?: string;
|
||||
setActiveId: (id: string) => void;
|
||||
disabledExtensions: string[];
|
||||
setDisabledExtensions: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
export const ExtensionsContext = createContext<ExtensionsContextType>({
|
||||
setActiveId: noop,
|
||||
disabledExtensions: [],
|
||||
setDisabledExtensions: noop,
|
||||
});
|
||||
|
||||
const Extensions = () => {
|
||||
const { t } = useTranslation();
|
||||
const disabledExtensions = useExtensionStore((state) => {
|
||||
return state.disabledExtensions;
|
||||
});
|
||||
const setDisabledExtensions = useExtensionStore((state) => {
|
||||
return state.setDisabledExtensions;
|
||||
});
|
||||
|
||||
useMount(async () => {
|
||||
const disabledExtensions = await platformAdapter.invokeBackend<string[]>(
|
||||
"get_disabled_local_query_sources"
|
||||
);
|
||||
|
||||
console.log("disabledExtensions", disabledExtensions);
|
||||
|
||||
setDisabledExtensions(disabledExtensions);
|
||||
});
|
||||
|
||||
const allApps = useApplicationsStore((state) => {
|
||||
return state.allApps;
|
||||
@@ -45,13 +68,18 @@ const Extensions = () => {
|
||||
|
||||
const presetPlugins: Plugin[] = [
|
||||
{
|
||||
id: "1",
|
||||
id: "Applications",
|
||||
icon: <Folder />,
|
||||
title: t("settings.extensions.application.title"),
|
||||
type: "Group",
|
||||
content: <ApplicationsContent />,
|
||||
detail: <ApplicationsDetail />,
|
||||
},
|
||||
{
|
||||
id: "Calculator",
|
||||
icon: <Calculator />,
|
||||
title: t("settings.extensions.calculator.title"),
|
||||
},
|
||||
// {
|
||||
// id: "2",
|
||||
// icon: <File />,
|
||||
@@ -69,7 +97,7 @@ const Extensions = () => {
|
||||
|
||||
const currentApp = useMemo(() => {
|
||||
return allApps.find((app) => {
|
||||
return app.name === activeId;
|
||||
return app.path === activeId;
|
||||
});
|
||||
}, [activeId, allApps]);
|
||||
|
||||
@@ -78,6 +106,8 @@ const Extensions = () => {
|
||||
value={{
|
||||
activeId,
|
||||
setActiveId,
|
||||
disabledExtensions,
|
||||
setDisabledExtensions,
|
||||
}}
|
||||
>
|
||||
<div className="flex h-[calc(100vh-128px)] -mx-6 gap-4">
|
||||
@@ -123,7 +153,7 @@ const Extensions = () => {
|
||||
|
||||
{currentPlugin?.detail}
|
||||
|
||||
{currentApp && <Application current={currentApp} />}
|
||||
{currentApp && <Application />}
|
||||
</div>
|
||||
</div>
|
||||
</ExtensionsContext.Provider>
|
||||
|
||||
@@ -45,7 +45,7 @@ const UpdateApp = ({ checkUpdate, relaunchApp }: UpdateAppProps) => {
|
||||
const checkUpdateStatus = useCallback(async () => {
|
||||
const update = await checkUpdate();
|
||||
|
||||
if (update?.available) {
|
||||
if (update) {
|
||||
setUpdateInfo(update);
|
||||
|
||||
if (skipVersion === update.version) return;
|
||||
|
||||
@@ -200,7 +200,9 @@
|
||||
"title": "Applications",
|
||||
"hits": {
|
||||
"addAlias": "Add Alias",
|
||||
"recordHotkey": "Record Hotkey"
|
||||
"recordHotkey": "Record Hotkey",
|
||||
"pathDuplication": "Path \"{{0}}\" is already in search scope.",
|
||||
"pathIncluded": "Path \"{{0}}\" is already covered by another search directory."
|
||||
},
|
||||
"button": {
|
||||
"addDirectories": "Add Directories"
|
||||
@@ -217,6 +219,9 @@
|
||||
"modified": "Modified",
|
||||
"lastOpened": "Last Opened"
|
||||
}
|
||||
},
|
||||
"calculator": {
|
||||
"title": "Calculator"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -200,7 +200,9 @@
|
||||
"title": "应用程序",
|
||||
"hits": {
|
||||
"addAlias": "添加别名",
|
||||
"recordHotkey": "录制热键"
|
||||
"recordHotkey": "录制热键",
|
||||
"pathDuplication": "路径 \"{{0}}\" 已存在于搜索范围中。",
|
||||
"pathIncluded": "路径 \"{{0}}\" 已被其他搜索目录包含。"
|
||||
},
|
||||
"button": {
|
||||
"addDirectories": "添加目录"
|
||||
@@ -217,6 +219,9 @@
|
||||
"modified": "修改时间",
|
||||
"lastOpened": "上次打开时间"
|
||||
}
|
||||
},
|
||||
"calculator": {
|
||||
"title": "计算器"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
|
||||
@@ -8,7 +7,5 @@ import "./i18n";
|
||||
import "./main.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</React.StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
);
|
||||
|
||||
@@ -12,8 +12,7 @@ import Footer from "@/components/Common/UI/SettingsFooter";
|
||||
import { useTray } from "@/hooks/useTray";
|
||||
import Advanced from "@/components/Settings/Advanced";
|
||||
import Extensions from "@/components/Settings/Extensions";
|
||||
import { useAsyncEffect, useMount } from "ahooks";
|
||||
import { useApplicationsStore } from "@/stores/applicationsStore";
|
||||
import { Application, useApplicationsStore } from "@/stores/applicationsStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
const tabIndexMap: { [key: string]: number } = {
|
||||
@@ -26,9 +25,9 @@ const tabIndexMap: { [key: string]: number } = {
|
||||
|
||||
function SettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const searchPaths = useApplicationsStore((state) => state.searchPaths);
|
||||
const setSearchPaths = useApplicationsStore((state) => state.setSearchPaths);
|
||||
const setAllApps = useApplicationsStore((state) => state.setAllApps);
|
||||
const allApps = useApplicationsStore((state) => state.allApps);
|
||||
|
||||
useTray();
|
||||
|
||||
@@ -60,30 +59,33 @@ function SettingsPage() {
|
||||
document.body.style.overflow = defaultIndex !== 1 ? "auto" : "hidden";
|
||||
}, [defaultIndex]);
|
||||
|
||||
useMount(async () => {
|
||||
if (searchPaths.length > 0) return;
|
||||
useEffect(() => {
|
||||
platformAdapter.listenEvent("search-source-loaded", async () => {
|
||||
const apps = await platformAdapter.invokeBackend<Application[]>(
|
||||
"get_app_list"
|
||||
);
|
||||
|
||||
const paths = await platformAdapter.invokeBackend<string[]>(
|
||||
"get_default_search_paths"
|
||||
);
|
||||
const sortedApps = apps.sort((a, b) => {
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
|
||||
});
|
||||
|
||||
setSearchPaths(paths);
|
||||
});
|
||||
setAllApps(sortedApps);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
if (searchPaths.length === 0) {
|
||||
return setAllApps([]);
|
||||
}
|
||||
const paths = await platformAdapter.invokeBackend<string[]>(
|
||||
"get_app_search_path"
|
||||
);
|
||||
|
||||
const apps = await platformAdapter.invokeBackend<any[]>(
|
||||
"list_app_with_metadata_in",
|
||||
{
|
||||
searchPath: searchPaths,
|
||||
}
|
||||
);
|
||||
setSearchPaths(paths);
|
||||
});
|
||||
|
||||
setAllApps(apps);
|
||||
}, [searchPaths]);
|
||||
platformAdapter.listenEvent("new-apps", ({ payload }) => {
|
||||
const nextApps = allApps.concat(payload).sort((a, b) => {
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
|
||||
});
|
||||
|
||||
setAllApps(nextApps);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -56,7 +56,7 @@ export type IAppStore = {
|
||||
setLanguage: (language: string) => void;
|
||||
isPinned: boolean;
|
||||
setIsPinned: (isPinned: boolean) => void;
|
||||
initializeListeners: () => void;
|
||||
initializeListeners: () => Promise<() => void>;
|
||||
|
||||
showCocoShortcuts: string[];
|
||||
setShowCocoShortcuts: (showCocoShortcuts: string[]) => void;
|
||||
@@ -130,10 +130,14 @@ export const useAppStore = create<IAppStore>()(
|
||||
isPinned: false,
|
||||
setIsPinned: (isPinned: boolean) => set({ isPinned }),
|
||||
initializeListeners: () => {
|
||||
platformAdapter.listenEvent(ENDPOINT_CHANGE_EVENT, (event: any) => {
|
||||
const { endpoint, endpoint_http, endpoint_websocket } = event.payload;
|
||||
set({ endpoint, endpoint_http, endpoint_websocket });
|
||||
});
|
||||
return platformAdapter.listenEvent(
|
||||
ENDPOINT_CHANGE_EVENT,
|
||||
(event: any) => {
|
||||
const { endpoint, endpoint_http, endpoint_websocket } =
|
||||
event.payload;
|
||||
set({ endpoint, endpoint_http, endpoint_websocket });
|
||||
}
|
||||
);
|
||||
},
|
||||
showCocoShortcuts: [],
|
||||
setShowCocoShortcuts: (showCocoShortcuts: string[]) => {
|
||||
|
||||
@@ -2,10 +2,19 @@ import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export interface Application {
|
||||
path: string;
|
||||
name: string;
|
||||
iconPath: string;
|
||||
alias: string;
|
||||
hotkey: string;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
export interface ApplicationMetadata {
|
||||
name: string;
|
||||
where: string;
|
||||
size: number;
|
||||
icon: string;
|
||||
where: string;
|
||||
created: number;
|
||||
modified: number;
|
||||
lastOpened: number;
|
||||
@@ -16,8 +25,6 @@ export type IUpdateStore = {
|
||||
setAllApps: (appApps: Application[]) => void;
|
||||
searchPaths: string[];
|
||||
setSearchPaths: (searchPaths: string[]) => void;
|
||||
disabledApps: string[];
|
||||
setDisabledApps: (disabledApps: string[]) => void;
|
||||
};
|
||||
|
||||
export const useApplicationsStore = create<IUpdateStore>()(
|
||||
@@ -31,17 +38,10 @@ export const useApplicationsStore = create<IUpdateStore>()(
|
||||
setSearchPaths: (searchPaths: string[]) => {
|
||||
return set({ searchPaths });
|
||||
},
|
||||
disabledApps: [],
|
||||
setDisabledApps: (disabledApps: string[]) => {
|
||||
return set({ disabledApps });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "applications-store",
|
||||
partialize: (state) => ({
|
||||
searchPaths: state.searchPaths,
|
||||
disabledApps: state.disabledApps,
|
||||
}),
|
||||
partialize: () => ({}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { produce } from 'immer'
|
||||
import { produce } from "immer";
|
||||
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
const AUTH_CHANGE_EVENT = 'auth-changed';
|
||||
const USERINFO_CHANGE_EVENT = 'userInfo-changed';
|
||||
const AUTH_CHANGE_EVENT = "auth-changed";
|
||||
const USERINFO_CHANGE_EVENT = "userInfo-changed";
|
||||
|
||||
export type Plan = {
|
||||
upgraded: boolean;
|
||||
@@ -33,7 +33,7 @@ export type IAuthStore = {
|
||||
userInfo: userInfoMapProp;
|
||||
setAuth: (auth: AuthProp | undefined, key: string) => void;
|
||||
resetAuth: (key: string) => void;
|
||||
initializeListeners: () => void;
|
||||
initializeListeners: () => Promise<() => void>;
|
||||
};
|
||||
|
||||
export const useAuthStore = create<IAuthStore>()(
|
||||
@@ -44,59 +44,62 @@ export const useAuthStore = create<IAuthStore>()(
|
||||
setAuth: async (auth, key) => {
|
||||
set(
|
||||
produce((draft) => {
|
||||
draft.auth[key] = auth
|
||||
draft.auth[key] = auth;
|
||||
})
|
||||
);
|
||||
|
||||
await platformAdapter.emitEvent(AUTH_CHANGE_EVENT, {
|
||||
auth: {
|
||||
[key]: auth
|
||||
}
|
||||
[key]: auth,
|
||||
},
|
||||
});
|
||||
},
|
||||
resetAuth: async (key: string) => {
|
||||
set(
|
||||
produce((draft) => {
|
||||
draft.auth[key] = undefined
|
||||
draft.auth[key] = undefined;
|
||||
})
|
||||
);
|
||||
|
||||
await platformAdapter.emitEvent(AUTH_CHANGE_EVENT, {
|
||||
auth: {
|
||||
[key]: undefined
|
||||
}
|
||||
[key]: undefined,
|
||||
},
|
||||
});
|
||||
},
|
||||
setUserInfo: async (userInfo: any, key: string) => {
|
||||
set(
|
||||
produce((draft) => {
|
||||
draft.userInfo[key] = userInfo
|
||||
draft.userInfo[key] = userInfo;
|
||||
})
|
||||
);
|
||||
|
||||
await platformAdapter.emitEvent(USERINFO_CHANGE_EVENT, {
|
||||
userInfo: {
|
||||
[key]: userInfo
|
||||
}
|
||||
[key]: userInfo,
|
||||
},
|
||||
});
|
||||
},
|
||||
initializeListeners: () => {
|
||||
platformAdapter.listenEvent(AUTH_CHANGE_EVENT, (event: any) => {
|
||||
initializeListeners: async () => {
|
||||
await platformAdapter.listenEvent(AUTH_CHANGE_EVENT, (event: any) => {
|
||||
const { auth } = event.payload;
|
||||
set({ auth });
|
||||
});
|
||||
|
||||
platformAdapter.listenEvent(USERINFO_CHANGE_EVENT, (event: any) => {
|
||||
const { userInfo } = event.payload;
|
||||
set({ userInfo });
|
||||
});
|
||||
return platformAdapter.listenEvent(
|
||||
USERINFO_CHANGE_EVENT,
|
||||
(event: any) => {
|
||||
const { userInfo } = event.payload;
|
||||
set({ userInfo });
|
||||
}
|
||||
);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "auth-store",
|
||||
partialize: (state) => ({
|
||||
auth: state.auth,
|
||||
userInfo: state.userInfo
|
||||
userInfo: state.userInfo,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
24
src/stores/extension.ts
Normal file
24
src/stores/extension.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { create } from "zustand";
|
||||
import { persist, subscribeWithSelector } from "zustand/middleware";
|
||||
|
||||
export type IExtensionStore = {
|
||||
disabledExtensions: string[];
|
||||
setDisabledExtensions: (disabledExtensions: string[]) => void;
|
||||
};
|
||||
|
||||
export const useExtensionStore = create<IExtensionStore>()(
|
||||
subscribeWithSelector(
|
||||
persist(
|
||||
(set) => ({
|
||||
disabledExtensions: [],
|
||||
setDisabledExtensions: (disabledExtensions) => {
|
||||
return set({ disabledExtensions });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "extension-store",
|
||||
partialize: () => ({}),
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IAppearanceStore } from "@/stores/appearance";
|
||||
import { Application } from "@/stores/applicationsStore";
|
||||
import { IConnectStore } from "@/stores/connectStore";
|
||||
import { IShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { IStartupStore } from "@/stores/startupStore";
|
||||
@@ -40,6 +41,8 @@ export interface EventPayloads {
|
||||
"change-shortcuts-store": IShortcutsStore;
|
||||
"change-connect-store": IConnectStore;
|
||||
"change-appearance-store": IAppearanceStore;
|
||||
"search-source-loaded": any;
|
||||
"new-apps": Application;
|
||||
}
|
||||
|
||||
// Window operation interface
|
||||
|
||||
Reference in New Issue
Block a user