15 Commits

Author SHA1 Message Date
rain9
7dce440830 chore: update 2025-12-25 17:28:27 +08:00
rain9
1614291f94 docs: update release note 2025-12-25 15:40:10 +08:00
rain9
1806be2a99 fix: avoid recentering when resizing to compact after leaving ViewExtension 2025-12-25 15:27:07 +08:00
ayangweb
913572756c feat: add search filter (#1014)
* feat: add search filter

* refactor: update

* debugging: print query strings

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* update

* update

* update

* style: remove unless file

* update

* update

* pass v2=true to coco server

* print response body string

* pass request body

* chore: up

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* Bucket.label

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* filter

* refactor: update

* fix: source ID filter

* refactor: update

* refactor: update

* fix: source ID filter

* refactor: update

* fix: when field buckets does not exist

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* docs: document supported query strings

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* remove debugging printlns

* move clean_aggregations() to common/search.rs

* fix import

* typo filer->filter

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* update

* update filter=updated filter=created format

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
Co-authored-by: rain9 <15911122312@163.com>
2025-12-25 14:03:59 +08:00
SteveLauC
e5ff39b806 refactor: add a timeout to open() (#1025)
Adds a 500ms timeout to open(), to prevent the open action that hangs
from hanging indefinitely.
2025-12-25 13:23:57 +08:00
SteveLauC
b5afed45e2 feat: support app search even if Spotlight is disabled (#1028)
* feat: support app search even if Spotlight is disabled

Previously, we relied on Spotlight (mdfind) to fetch the app list,
which means it won't work if users disable their Spotlight index.

This commit bumps the applications-rs library, which can now list
apps using `lsregister`, macOS's launch service tool. (See commit [1]
for details). With this, our app search works even tough Spotlight
is disabled.

[1]: ec174b7761

* release notes
2025-12-25 09:02:50 +08:00
SteveLauC
062b4ce410 refactor: display doc.{updated,created} in local time (#1027)
* refactor: display doc.{updated,created} in local time

Previously, document created/updated times were displayed as raw
strings, and can be in UTC, which was not user-friendly.

This commit adds a helper function `formatDateToLocal()` that formats
date to a local time string, then uses it to unify the date formatting
in application and document details.

* chore: address review comments
2025-12-24 16:23:12 +08:00
BiggerRain
03af1d46c5 fix: skip window resize when UI size is missing (#1023)
* fix: skip window resize when UI size is missing

* chore: padding

* chore: update

* refactor: update

* chore: height

* chore: height

* chore: defalut value

---------

Co-authored-by: ayang <473033518@qq.com>
2025-12-23 22:02:19 +08:00
SteveLauC
8638724e68 chore: remove file foo (#1026)
It was a placeholder that I added in PR #1009, I forgot to remove it
before merging that PR. Let's remove it now.
2025-12-22 16:12:14 +08:00
ayangweb
6ae2ed0832 fix: esc key fails to close the popover (#1024)
* fix: esc key fails to close the popover

* refactor: update
2025-12-22 11:51:04 +08:00
Hardy
81dab997a9 chore: update release notes for publish 0.10.0-2619 (#1021)
* chore: update release notes for publish 0.10.0-2619

* Add section headers to release notes

Added section headers for breaking changes, features, bug fixes, and improvements.

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: BiggerRain <15911122312@163.COM>
2025-12-22 09:51:59 +08:00
BiggerRain
7a4364665e chore: bump version to 0.10.0 (#1022) 2025-12-19 17:56:32 +08:00
BiggerRain
444ba968c4 chore: hide selection settings (#1020) 2025-12-19 16:15:03 +08:00
ayangweb
206d8db4f4 refactor: disable input auto-correction (#1019) 2025-12-19 15:46:53 +08:00
BiggerRain
7e40632c29 style: adjust delete button color (#1018) 2025-12-19 12:59:58 +08:00
61 changed files with 2054 additions and 745 deletions

View File

@@ -10,6 +10,7 @@
"dataurl",
"deeplink",
"deepthink",
"Detch",
"dtolnay",
"dyld",
"elif",
@@ -56,6 +57,7 @@
"rgba",
"rustup",
"screenshotable",
"seprate",
"serde",
"Shadcn",
"swatinem",
@@ -70,6 +72,7 @@
"unlisten",
"unlistener",
"unlisteners",
"unmaximize",
"unminimize",
"uuidv",
"VITE",
@@ -84,12 +87,10 @@
"editor.defaultFormatter": "rust-lang.rust-analyzer",
"editor.formatOnSave": true
},
"i18n-ally.localesPaths": [
"src/locales"
],
"i18n-ally.localesPaths": ["src/locales"],
"i18n-ally.keystyle": "nested",
"editor.tabSize": 2,
"editor.insertSpaces": true,
"editor.detectIndentation": false,
"i18n-ally.displayLanguage": "zh"
}
}

View File

@@ -7,7 +7,23 @@ title: "Release Notes"
Information about release notes of Coco App is provided here.
## Latest (In development)
## Latest (In development)
### ❌ Breaking changes
### 🚀 Features
- feat: support app search even if Spotlight is disabled #1028
### 🐛 Bug fix
- fix: avoid recentering when resizing to compact after leaving extension #1030
### ✈️ Improvements
- refactor: add a timeout to open() #1025
## 0.10.0 (2025-12-19)
### ❌ Breaking changes

0
foo
View File

View File

@@ -1,7 +1,7 @@
{
"name": "coco",
"private": true,
"version": "0.9.1",
"version": "0.10.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -51,17 +51,19 @@
"axios": "^1.12.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"dotenv": "^16.5.0",
"filesize": "^10.1.6",
"i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.1.0",
"lodash-es": "^4.17.21",
"lucide-react": "^0.461.0",
"lucide-react": "^0.561.0",
"mdast-util-gfm-autolink-literal": "2.0.0",
"mermaid": "^11.6.0",
"nanoid": "^5.1.5",
"react": "^18.3.1",
"react-day-picker": "^9.13.0",
"react-dom": "^18.3.1",
"react-hotkeys-hook": "^4.6.2",
"react-i18next": "^15.5.1",

52
pnpm-lock.yaml generated
View File

@@ -10,7 +10,7 @@ importers:
dependencies:
'@infinilabs/custom-icons':
specifier: 0.0.4
version: 0.0.4(lucide-react@0.461.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
version: 0.0.4(lucide-react@0.561.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-checkbox':
specifier: ^1.1.5
version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -104,6 +104,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
date-fns:
specifier: ^4.1.0
version: 4.1.0
dayjs:
specifier: ^1.11.13
version: 1.11.18
@@ -123,8 +126,8 @@ importers:
specifier: ^4.17.21
version: 4.17.21
lucide-react:
specifier: ^0.461.0
version: 0.461.0(react@18.3.1)
specifier: ^0.561.0
version: 0.561.0(react@18.3.1)
mdast-util-gfm-autolink-literal:
specifier: 2.0.0
version: 2.0.0
@@ -137,6 +140,9 @@ importers:
react:
specifier: ^18.3.1
version: 18.3.1
react-day-picker:
specifier: ^9.13.0
version: 9.13.0(react@18.3.1)
react-dom:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
@@ -408,6 +414,9 @@ packages:
'@chevrotain/utils@11.0.3':
resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==}
'@date-fns/tz@1.4.1':
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
'@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'}
@@ -2469,6 +2478,12 @@ packages:
resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
engines: {node: '>= 14'}
date-fns-jalali@4.1.0-0:
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
dayjs@1.11.18:
resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==}
@@ -3246,10 +3261,10 @@ packages:
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
engines: {node: '>=12'}
lucide-react@0.461.0:
resolution: {integrity: sha512-Scpw3D/dV1bgVRC5Kh774RCm99z0iZpPv75M6kg7QL1lLvkQ1rmI1Sjjic1aGp1ULBwd7FokV6ry0g+d6pMB+w==}
lucide-react@0.561.0:
resolution: {integrity: sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
macos-release@3.3.0:
resolution: {integrity: sha512-tPJQ1HeyiU2vRruNGhZ+VleWuMQRro8iFtJxYgnS4NQe+EukKF6aGiIT+7flZhISAt2iaXBCfFGvAyif7/f8nQ==}
@@ -3700,6 +3715,12 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
react-day-picker@9.13.0:
resolution: {integrity: sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==}
engines: {node: '>=18'}
peerDependencies:
react: '>=16.8.0'
react-dom@18.3.1:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
@@ -4574,6 +4595,8 @@ snapshots:
'@chevrotain/utils@11.0.3': {}
'@date-fns/tz@1.4.1': {}
'@esbuild/aix-ppc64@0.21.5':
optional: true
@@ -4752,9 +4775,9 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@infinilabs/custom-icons@0.0.4(lucide-react@0.461.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
'@infinilabs/custom-icons@0.0.4(lucide-react@0.561.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
lucide-react: 0.461.0(react@18.3.1)
lucide-react: 0.561.0(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
@@ -6477,6 +6500,10 @@ snapshots:
data-uri-to-buffer@6.0.2: {}
date-fns-jalali@4.1.0-0: {}
date-fns@4.1.0: {}
dayjs@1.11.18: {}
debug@4.4.0:
@@ -7261,7 +7288,7 @@ snapshots:
lru-cache@7.18.3: {}
lucide-react@0.461.0(react@18.3.1):
lucide-react@0.561.0(react@18.3.1):
dependencies:
react: 18.3.1
@@ -7961,6 +7988,13 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
react-day-picker@9.13.0(react@18.3.1):
dependencies:
'@date-fns/tz': 1.4.1
date-fns: 4.1.0
date-fns-jalali: 4.1.0-0
react: 18.3.1
react-dom@18.3.1(react@18.3.1):
dependencies:
loose-envify: 1.4.0

4
src-tauri/Cargo.lock generated
View File

@@ -332,7 +332,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "applications"
version = "0.3.1"
source = "git+https://github.com/infinilabs/applications-rs?rev=b5fac4034a40d42e72f727f1aa1cc1f19fe86653#b5fac4034a40d42e72f727f1aa1cc1f19fe86653"
source = "git+https://github.com/infinilabs/applications-rs?rev=ec174b7761bfa5eb7af0a93218b014e2d1505643#ec174b7761bfa5eb7af0a93218b014e2d1505643"
dependencies = [
"anyhow",
"core-foundation 0.9.4",
@@ -1132,7 +1132,7 @@ dependencies = [
[[package]]
name = "coco"
version = "0.9.1"
version = "0.10.0"
dependencies = [
"actix-files",
"actix-web",

View File

@@ -1,6 +1,6 @@
[package]
name = "coco"
version = "0.9.1"
version = "0.10.0"
description = "Search, connect, collaborate all in one place."
authors = ["INFINI Labs"]
edition = "2024"
@@ -62,7 +62,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 = "b5fac4034a40d42e72f727f1aa1cc1f19fe86653" }
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "ec174b7761bfa5eb7af0a93218b014e2d1505643" }
tokio-native-tls = "0.3" # For wss connections
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = { version = "0.20", features = ["native-tls"] }

View File

@@ -148,152 +148,171 @@ pub(crate) async fn open(
extra_args: Option<HashMap<String, Json>>,
) -> Result<(), String> {
use crate::util::open as homemade_tauri_shell_open;
use std::process::Command;
use tokio::process::Command;
use tokio::time::Duration;
use tokio::time::timeout;
match on_opened {
OnOpened::Application { app_path } => {
log::debug!("open application [{}]", app_path);
let on_opened_clone = on_opened.clone();
// Put the main logic in an async closure so that we can `time::timeout()`
// it
let async_closure = async move {
match on_opened_clone {
OnOpened::Application { app_path } => {
log::debug!("open application [{}]", app_path);
homemade_tauri_shell_open(tauri_app_handle.clone(), app_path).await?
}
OnOpened::Document { url } => {
log::debug!("open document [{}]", url);
homemade_tauri_shell_open(tauri_app_handle.clone(), app_path).await?
}
OnOpened::Document { url } => {
log::debug!("open document [{}]", url);
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
}
#[cfg(target_os = "macos")]
OnOpened::WindowManagementAction { action } => {
log::debug!("perform Window Management action [{:?}]", action);
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
}
#[cfg(target_os = "macos")]
OnOpened::WindowManagementAction { action } => {
log::debug!("perform Window Management action [{:?}]", action);
crate::extension::built_in::window_management::perform_action_on_main_thread(
&tauri_app_handle,
action,
)?;
}
OnOpened::Extension(ext_on_opened) => {
// Apply the settings that would affect open behavior
if let Some(settings) = ext_on_opened.settings {
if let Some(should_hide) = settings.hide_before_open {
if should_hide {
crate::hide_coco(tauri_app_handle.clone()).await;
crate::extension::built_in::window_management::perform_action_on_main_thread(
&tauri_app_handle,
action,
)?;
}
OnOpened::Extension(ext_on_opened) => {
// Apply the settings that would affect open behavior
if let Some(settings) = ext_on_opened.settings {
if let Some(should_hide) = settings.hide_before_open {
if should_hide {
crate::hide_coco(tauri_app_handle.clone()).await;
}
}
}
}
let permission = ext_on_opened.permission;
let permission = ext_on_opened.permission;
match ext_on_opened.ty {
ExtensionOnOpenedType::Command { action } => {
log::debug!("open (execute) command [{:?}]", action);
match ext_on_opened.ty {
ExtensionOnOpenedType::Command { action } => {
log::debug!("open (execute) command [{:?}]", action);
let mut cmd = Command::new(action.exec);
if let Some(args) = action.args {
cmd.args(args);
}
let output = cmd.output().map_err(|e| e.to_string())?;
// Sometimes, we wanna see the result in logs even though it doesn't fail.
log::debug!(
"executing open(Command) result, exit code: [{}], stdout: [{}], stderr: [{}]",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
if !output.status.success() {
log::warn!(
"executing open(Command) failed, exit code: [{}], stdout: [{}], stderr: [{}]",
let mut cmd = Command::new(action.exec);
if let Some(args) = action.args {
cmd.args(args);
}
let output = cmd.output().await.map_err(|e| e.to_string())?;
// Sometimes, we wanna see the result in logs even though it doesn't fail.
log::debug!(
"executing open(Command) result, exit code: [{}], stdout: [{}], stderr: [{}]",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return Err(format!(
"Command failed, stderr [{}]",
String::from_utf8_lossy(&output.stderr)
));
}
}
ExtensionOnOpenedType::Quicklink {
link,
open_with: opt_open_with,
} => {
let url = link.concatenate_url(&extra_args);
log::debug!("open quicklink [{}] with [{:?}]", url, opt_open_with);
cfg_if::cfg_if! {
// The `open_with` functionality is only supported on macOS, provided
// by the `open -a` command.
if #[cfg(target_os = "macos")] {
let mut cmd = Command::new("open");
if let Some(ref open_with) = opt_open_with {
cmd.arg("-a");
cmd.arg(open_with.as_str());
}
cmd.arg(&url);
let output = cmd.output().map_err(|e| format!("failed to spawn [open] due to error [{}]", e))?;
if !output.status.success() {
return Err(format!(
"failed to open with app {:?}: {}",
opt_open_with,
if !output.status.success() {
log::warn!(
"executing open(Command) failed, exit code: [{}], stdout: [{}], stderr: [{}]",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
));
}
} else {
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
);
return Err(format!(
"Command failed, stderr [{}]",
String::from_utf8_lossy(&output.stderr)
));
}
}
}
ExtensionOnOpenedType::View {
name,
icon,
page,
ui,
} => {
let page_path = Utf8Path::new(&page);
let directory = page_path.parent().unwrap_or_else(|| {
panic!("View extension page path should have a parent, i.e., it should be under a directory, but [{}] does not", page);
});
let mut url = serve_files_in(directory.as_ref()).await;
ExtensionOnOpenedType::Quicklink {
link,
open_with: opt_open_with,
} => {
let url = link.concatenate_url(&extra_args);
/*
* Emit an event to let the frontend code open this extension.
*
* Payload `view_extension_opened` contains the information needed
* to do that.
*
* See "src/pages/main/index.tsx" for more info.
*/
use camino::Utf8Path;
use serde_json::Value as Json;
use serde_json::to_value;
log::debug!("open quicklink [{}] with [{:?}]", url, opt_open_with);
let html_filename = page_path
.file_name()
.unwrap_or_else(|| {
panic!("View extension page path should have a file name, but [{}] does not have one", page);
}).to_string();
url.push('/');
url.push_str(&html_filename);
cfg_if::cfg_if! {
// The `open_with` functionality is only supported on macOS, provided
// by the `open -a` command.
if #[cfg(target_os = "macos")] {
let mut cmd = Command::new("open");
if let Some(ref open_with) = opt_open_with {
cmd.arg("-a");
cmd.arg(open_with.as_str());
}
cmd.arg(&url);
let html_file_url = url;
debug!("View extension listening on: {}", html_file_url);
let view_extension_opened: [Json; 5] = [
Json::String(name),
Json::String(icon),
Json::String(html_file_url),
to_value(permission).unwrap(),
to_value(ui).unwrap(),
];
tauri_app_handle
.emit("open_view_extension", view_extension_opened)
.unwrap();
let output = cmd.output().await.map_err(|e| format!("failed to spawn [open] due to error [{}]", e))?;
if !output.status.success() {
return Err(format!(
"failed to open with app {:?}: {}",
opt_open_with,
String::from_utf8_lossy(&output.stderr)
));
}
} else {
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
}
}
}
ExtensionOnOpenedType::View {
name,
icon,
page,
ui,
} => {
let page_path = Utf8Path::new(&page);
let directory = page_path.parent().unwrap_or_else(|| {
panic!("View extension page path should have a parent, i.e., it should be under a directory, but [{}] does not", page);
});
let mut url = serve_files_in(directory.as_ref()).await;
/*
* Emit an event to let the frontend code open this extension.
*
* Payload `view_extension_opened` contains the information needed
* to do that.
*
* See "src/pages/main/index.tsx" for more info.
*/
use camino::Utf8Path;
use serde_json::Value as Json;
use serde_json::to_value;
let html_filename = page_path
.file_name()
.unwrap_or_else(|| {
panic!("View extension page path should have a file name, but [{}] does not have one", page);
}).to_string();
url.push('/');
url.push_str(&html_filename);
let html_file_url = url;
debug!("View extension listening on: {}", html_file_url);
let view_extension_opened: [Json; 5] = [
Json::String(name),
Json::String(icon),
Json::String(html_file_url),
to_value(permission).unwrap(),
to_value(ui).unwrap(),
];
tauri_app_handle
.emit("open_view_extension", view_extension_opened)
.unwrap();
}
}
}
}
}
Ok(())
Ok(())
};
match timeout(Duration::from_millis(500), async_closure).await {
Ok(res) => res,
Err(_timed_out) => {
log::warn!("executing open(on_opened: [{:?}]) timed out", on_opened);
Err(format!(
"executing open(on_opened: {:?}) timed out",
on_opened
))
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]

View File

@@ -1,6 +1,7 @@
use crate::common::document::Document;
use crate::common::http::get_response_body_text;
use reqwest::Response;
use serde::de::Deserializer;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::error::Error;
@@ -11,6 +12,7 @@ pub struct SearchResponse<T> {
pub timed_out: Option<bool>,
pub _shards: Option<Shards>,
pub hits: Hits<T>,
pub aggregations: Option<Aggregations>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -122,11 +124,182 @@ pub struct FailedRequest {
pub reason: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Aggregation {
// Frontend code needs this field to not be NULL, so we call
// `clean_aggregations()` in query_coco_fusion() to ensure this.
pub buckets: Option<Vec<AggBucket>>,
}
/// An aggregation bucket.
#[derive(Debug, Serialize, Clone)]
pub struct AggBucket {
/// The number of documents contained in this bucket
doc_count: usize,
/// Bucket key, the field's value.
key: String,
/// In the cases where `key` is not human-readable, `label` should be Some.
///
/// Optional human label extracted from `top.hits.hits[0]._source.source.name`.
label: Option<String>,
}
/// An aggregation bucket does not have a `label` field, it is extracted from
/// `top.hits.hits[0]._source.source.name`. We manually implement Deserialize
/// to do this extraction job.
impl<'de> Deserialize<'de> for AggBucket {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct Wrapper {
doc_count: usize,
key: String,
#[serde(default)]
top: Option<Top>,
}
#[derive(Deserialize)]
struct Top {
hits: TopHits,
}
#[derive(Deserialize)]
struct TopHits {
hits: Vec<TopHit>,
}
#[derive(Deserialize)]
struct TopHit {
#[serde(default)]
_source: Option<TopSource>,
}
#[derive(Deserialize)]
struct TopSource {
#[serde(default)]
source: Option<SourceLabel>,
}
#[derive(Deserialize)]
struct SourceLabel {
#[serde(default)]
name: Option<String>,
}
let wrapper = Wrapper::deserialize(deserializer)?;
let label = wrapper
.top
.and_then(|top| top.hits.hits.into_iter().next())
.and_then(|hit| hit._source)
.and_then(|src| src.source)
.and_then(|lbl| lbl.name);
Ok(AggBucket {
doc_count: wrapper.doc_count,
key: wrapper.key,
label,
})
}
}
/// Coco server aggregation result.
///
/// ```json
/// {
/// "type": {
/// "buckets": [
/// {
/// "doc_count": 26,
/// "key": "web_page"
/// },
/// {
/// "doc_count": 1,
/// "key": "pdf"
/// }
/// ]
/// },
/// "lang": {
/// "buckets": [
/// {
/// "doc_count": 30,
/// "key": "en"
/// }
/// ]
/// }
/// }
/// ```
pub type Aggregations = HashMap<String, Aggregation>;
/// Helper function to drop empty aggregations and normalize `Option` state.
pub(crate) fn clean_aggregations(aggs: &mut Option<Aggregations>) {
if let Some(map) = aggs {
map.retain(|_, agg| match &agg.buckets {
Some(buckets) => !buckets.is_empty(),
None => false,
});
if map.is_empty() {
*aggs = None;
}
}
}
/// Merge the buckets in `from` to `to`.
pub(crate) fn merge_aggregations(to: &mut Option<Aggregations>, from: Aggregations) {
use std::collections::hash_map::Entry;
if from.is_empty() {
return;
}
match to {
None => {
*to = Some(from);
}
Some(to_map) => {
for (agg_name, agg) in from {
match to_map.entry(agg_name) {
Entry::Occupied(mut occ) => {
let to_agg = occ.get_mut();
if let Some(from_buckets) = agg.buckets {
match &mut to_agg.buckets {
Some(to_buckets) => {
for bucket in from_buckets {
if let Some(existing) = to_buckets
.iter_mut()
.find(|existing| existing.key == bucket.key)
{
existing.doc_count += bucket.doc_count;
} else {
to_buckets.push(bucket);
}
}
}
None => {
to_agg.buckets = Some(from_buckets);
}
}
}
}
Entry::Vacant(vacant) => {
vacant.insert(agg);
}
};
}
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct QueryResponse {
pub source: QuerySource,
pub hits: Vec<(Document, f64)>,
pub total_hits: usize,
pub aggregations: Option<Aggregations>,
}
#[derive(Debug, Clone, Serialize)]
@@ -134,4 +307,121 @@ pub struct MultiSourceQueryResponse {
pub failed: Vec<FailedRequest>,
pub hits: Vec<QueryHits>,
pub total_hits: usize,
pub aggregations: Option<Aggregations>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json;
/// Helper function to create an `AggBucket`, used in tests.
fn bucket(key: &str, doc_count: usize) -> AggBucket {
AggBucket {
key: key.to_string(),
doc_count,
label: None,
}
}
/// Helper function to create an `Aggregation`, used in tests.
fn agg_with_buckets(buckets: Vec<AggBucket>) -> Aggregation {
Aggregation {
buckets: Some(buckets),
}
}
/// Helper to get `doc_count` from the bucket specified by `key`.
/// Returns `None` when buckets are absent or the key is missing.
fn get_doc_count(agg: &Aggregation, key: &str) -> Option<usize> {
agg.buckets
.as_ref()
.and_then(|buckets| buckets.iter().find(|b| b.key == key))
.map(|b| b.doc_count)
}
#[test]
fn merge_into_none_initializes() {
let mut to: Option<Aggregations> = None;
let mut from = Aggregations::new();
from.insert("terms".to_string(), agg_with_buckets(vec![bucket("a", 2)]));
merge_aggregations(&mut to, from);
let terms = to.unwrap().get("terms").cloned().unwrap();
assert_eq!(get_doc_count(&terms, "a"), Some(2));
}
#[test]
fn merge_sums_and_appends_buckets() {
let mut to_inner = Aggregations::new();
to_inner.insert(
"terms".to_string(),
agg_with_buckets(vec![bucket("a", 1), bucket("b", 2)]),
);
let mut to = Some(to_inner);
let mut from = Aggregations::new();
from.insert(
"terms".to_string(),
agg_with_buckets(vec![bucket("a", 3), bucket("c", 5)]),
);
from.insert(
"lang".to_string(),
agg_with_buckets(vec![bucket("zh", 3), bucket("en", 5)]),
);
merge_aggregations(&mut to, from);
let terms = to.as_ref().unwrap().get("terms").unwrap();
assert_eq!(get_doc_count(terms, "a"), Some(4));
assert_eq!(get_doc_count(terms, "b"), Some(2));
assert_eq!(get_doc_count(terms, "c"), Some(5));
let lang = to.as_ref().unwrap().get("lang").unwrap();
assert_eq!(get_doc_count(lang, "zh"), Some(3));
assert_eq!(get_doc_count(lang, "en"), Some(5));
}
#[test]
fn deserialize_bucket_with_label() {
let json = r#"
{
"doc_count": 251,
"key": "d23ek9gqlqbcd9e3uiig",
"top": {
"hits": {
"hits": [
{
"_source": {
"source": {
"name": "INFINI Easysearch"
}
}
}
]
}
}
}
"#;
let bucket: AggBucket = serde_json::from_str(json).unwrap();
assert_eq!(bucket.doc_count, 251);
assert_eq!(bucket.key, "d23ek9gqlqbcd9e3uiig");
assert_eq!(bucket.label.as_deref(), Some("INFINI Easysearch"));
}
#[test]
fn deserialize_bucket_without_top_sets_label_none() {
let json = r#"
{
"doc_count": 10,
"key": "no-top"
}
"#;
let bucket: AggBucket = serde_json::from_str(json).unwrap();
assert_eq!(bucket.doc_count, 10);
assert_eq!(bucket.key, "no-top");
assert_eq!(bucket.label, None);
}
}

View File

@@ -654,6 +654,8 @@ impl SearchSource for ApplicationSearchSource {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
});
}
@@ -689,6 +691,8 @@ impl SearchSource for ApplicationSearchSource {
source,
hits,
total_hits,
// Local search source does not support aggregations
aggregations: None,
})
}
}

View File

@@ -39,6 +39,8 @@ impl SearchSource for ApplicationSearchSource {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
})
}
}

View File

@@ -131,6 +131,8 @@ impl SearchSource for CalculatorSource {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
});
};
@@ -143,6 +145,8 @@ impl SearchSource for CalculatorSource {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
});
}
@@ -156,6 +160,8 @@ impl SearchSource for CalculatorSource {
source: query_source,
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
};
};
// If it is only a number, no need to evaluate it as the result is
@@ -167,6 +173,8 @@ impl SearchSource for CalculatorSource {
source: query_source,
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
};
}
@@ -199,12 +207,16 @@ impl SearchSource for CalculatorSource {
source: query_source,
hits: vec![(doc, base_score)],
total_hits: 1,
// Local search source does not support aggregations
aggregations: None,
}
}
Err(_) => QueryResponse {
source: query_source,
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
},
}
};

View File

@@ -51,6 +51,8 @@ impl SearchSource for FileSearchExtensionSearchSource {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
});
};
let from = usize::try_from(query.from).expect("from too big");
@@ -62,6 +64,8 @@ impl SearchSource for FileSearchExtensionSearchSource {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
});
}
@@ -77,6 +81,8 @@ impl SearchSource for FileSearchExtensionSearchSource {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
});
}
@@ -92,6 +98,8 @@ impl SearchSource for FileSearchExtensionSearchSource {
source: query_source,
hits,
total_hits,
// Local search source does not support aggregations
aggregations: None,
})
}
}

View File

@@ -38,6 +38,8 @@ impl SearchSource for WindowManagementSearchSource {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
});
};
let from = usize::try_from(query.from).expect("from too big");
@@ -49,6 +51,8 @@ impl SearchSource for WindowManagementSearchSource {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregation
aggregations: None,
});
}
let query_string_lowercase = query_string.to_lowercase();
@@ -133,6 +137,8 @@ impl SearchSource for WindowManagementSearchSource {
source: self.get_type(),
hits: from_size_applied,
total_hits,
// Local search source does not support aggregation
aggregations: None,
})
}
}

View File

@@ -156,13 +156,13 @@ pub struct Extension {
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub(crate) struct ViewExtensionUISettings {
/// Show the search bar
#[serde_inline_default(true)]
#[serde_inline_default(false)]
search_bar: bool,
/// Show the filter bar
#[serde_inline_default(true)]
#[serde_inline_default(false)]
filter_bar: bool,
/// Show the footer
#[serde_inline_default(true)]
#[serde_inline_default(false)]
footer: bool,
/// The recommended width of the window for this extension
width: Option<u32>,

View File

@@ -69,6 +69,8 @@ impl SearchSource for ExtensionStore {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
});
};
@@ -94,12 +96,16 @@ impl SearchSource for ExtensionStore {
source: self.get_type(),
hits: vec![(doc, SCORE)],
total_hits: 1,
// Local search source does not support aggregations
aggregations: None,
})
} else {
Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
// Local search source does not support aggregations
aggregations: None,
})
}
}

View File

@@ -1110,6 +1110,8 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
source: self.get_type(),
hits,
total_hits,
// Local search source does not support aggregations
aggregations: None,
})
}
}

View File

@@ -29,7 +29,6 @@ use tauri_plugin_autostart::MacosLauncher;
/// Tauri store name
pub(crate) const COCO_TAURI_STORE: &str = "coco_tauri_store";
pub(crate) const WINDOW_CENTER_BASELINE_HEIGHT: i32 = 590;
lazy_static! {
static ref PREVIOUS_MONITOR_NAME: Mutex<Option<String>> = Mutex::new(None);
@@ -44,37 +43,6 @@ lazy_static! {
/// you access it.
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();
let mut size = window.outer_size().unwrap();
size.height = height;
window.set_size(size).unwrap();
// Center the window horizontally and vertically based on the baseline height of 590
let monitor = window.primary_monitor().ok().flatten().or_else(|| {
window
.available_monitors()
.ok()
.and_then(|ms| ms.into_iter().next())
});
if let Some(monitor) = monitor {
let monitor_position = monitor.position();
let monitor_size = monitor.size();
let outer_size = window.outer_size().unwrap();
let window_width = outer_size.width as i32;
let x = monitor_position.x + (monitor_size.width as i32 - window_width) / 2;
let y =
monitor_position.y + (monitor_size.height as i32 - WINDOW_CENTER_BASELINE_HEIGHT) / 2;
let _ = window.set_position(PhysicalPosition::new(x, y));
}
}
// Removed unused Payload to avoid unnecessary serde derive macro invocations
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -123,7 +91,6 @@ pub fn run() {
let app = app_builder
.invoke_handler(tauri::generate_handler![
change_window_height,
shortcut::change_shortcut,
shortcut::unregister_shortcut,
shortcut::get_current_shortcut,
@@ -380,12 +347,13 @@ fn move_window_to_active_monitor(window: &WebviewWindow) {
return;
}
};
let window_width = window_size.width as i32;
let window_height = 590 * scale_factor as i32;
// Horizontal center uses actual width, vertical center uses 590 baseline
let window_x = monitor_position.x + (monitor_size.width as i32 - window_width) / 2;
let window_y = monitor_position.y
+ (monitor_size.height as i32 - WINDOW_CENTER_BASELINE_HEIGHT) / 2;
let window_y = monitor_position.y + (monitor_size.height as i32 - window_height) / 2;
if let Err(e) = window.set_position(PhysicalPosition::new(window_x, window_y)) {
log::error!("Failed to move window: {}", e);

View File

@@ -2,6 +2,7 @@ use crate::common::error::{ReportErrorStyle, SearchError, report_error};
use crate::common::register::SearchSourceRegistry;
use crate::common::search::{
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
merge_aggregations,
};
use crate::common::traits::SearchSource;
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
@@ -31,6 +32,9 @@ use tokio::time::{Duration, timeout};
/// ```
///
/// then only the extension with this ID will be returned, if exists.
///
/// * Some query string that are exclusive to Coco server, see `convert_query_string()`
/// in `src-tauri/src/server/search.rs`
#[named]
#[tauri::command]
pub async fn query_coco_fusion(
@@ -60,7 +64,7 @@ pub async fn query_coco_fusion(
);
// Dispatch to different `query_coco_fusion_xxx()` functions.
if let Some(query_source_id) = opt_query_source_id {
let mut res_response = if let Some(query_source_id) = opt_query_source_id {
query_coco_fusion_single_query_source(
tauri_app_handle,
query_source_list,
@@ -77,7 +81,13 @@ pub async fn query_coco_fusion(
search_query,
)
.await
};
if let Ok(ref mut response) = res_response {
crate::common::search::clean_aggregations(&mut response.aggregations);
}
res_response
}
/// Query only 1 query source.
@@ -121,6 +131,7 @@ async fn query_coco_fusion_single_query_source(
failed: Vec::new(),
hits: Vec::new(),
total_hits: 0,
aggregations: None,
});
};
@@ -132,6 +143,7 @@ async fn query_coco_fusion_single_query_source(
let mut failed_requests: Vec<FailedRequest> = Vec::new();
let mut hits = Vec::new();
let mut total_hits = 0;
let mut aggregations = None;
match timeout_result {
// Ignore the `_timeout` variable as it won't provide any useful debugging information.
@@ -144,6 +156,7 @@ async fn query_coco_fusion_single_query_source(
Ok(query_result) => match query_result {
Ok(response) => {
total_hits = response.total_hits;
aggregations = response.aggregations;
for (document, score) in response.hits {
log::debug!(
@@ -179,6 +192,7 @@ async fn query_coco_fusion_single_query_source(
failed: failed_requests,
hits,
total_hits,
aggregations,
})
}
@@ -227,6 +241,7 @@ async fn query_coco_fusion_multi_query_sources(
let mut total_hits = 0;
let mut failed_requests = Vec::new();
let mut all_hits_grouped_by_query_source: HashMap<QuerySource, Vec<QueryHits>> = HashMap::new();
let mut aggregations = None;
while let Some((query_source, timeout_result)) = futures.next().await {
match timeout_result {
@@ -240,6 +255,9 @@ async fn query_coco_fusion_multi_query_sources(
Ok(query_result) => match query_result {
Ok(response) => {
total_hits += response.total_hits;
if let Some(from) = response.aggregations {
merge_aggregations(&mut aggregations, from);
}
for (document, score) in response.hits {
log::debug!(
@@ -282,6 +300,7 @@ async fn query_coco_fusion_multi_query_sources(
failed: Vec::new(),
hits: Vec::new(),
total_hits: 0,
aggregations: None,
});
}
@@ -423,6 +442,7 @@ async fn query_coco_fusion_multi_query_sources(
failed: failed_requests,
hits: final_hits,
total_hits,
aggregations,
})
}

View File

@@ -75,6 +75,45 @@ pub struct CocoSearchSource {
server: Server,
}
/// Convert frontend query string key/value into coco server query param.
/// Returns `None` when the key is not recognized.
///
/// # Query strings that are exclusive to Coco server query source:
///
/// * fuzziness
/// * update_time_start
/// * update_time_end
/// * create_time_start
/// * create_time_end
/// * type
/// * source.id
/// * category
/// * subcategory
/// * lang
/// * tag
fn convert_query_string(key: &str, value: &str) -> Option<String> {
match key {
// existing single-value params
"querysource" | "datasource" | "query" | "fuzziness" => Some(format!("{}={}", key, value)),
// time range filters (single value)
"update_time_start" => Some(format!("filter=updated>={}", value)),
"update_time_end" => Some(format!("filter=updated<={}", value)),
"create_time_start" => Some(format!("filter=created>={}", value)),
"create_time_end" => Some(format!("filter=created<={}", value)),
// multi-value filters (value string may already contain any(...))
"type" => Some(format!("filter=type:{}", value)),
"source.id" => Some(format!("filter=source.id:{}", value)),
"category" => Some(format!("filter=category:{}", value)),
"subcategory" => Some(format!("filter=subcategory:{}", value)),
"lang" => Some(format!("filter=lang:{}", value)),
"tag" => Some(format!("filter=tag:{}", value)),
_ => None,
}
}
impl CocoSearchSource {
pub fn new(server: Server) -> Self {
CocoSearchSource { server }
@@ -99,6 +138,7 @@ impl SearchSource for CocoSearchSource {
let url = "/query/_search";
let mut total_hits = 0;
let mut hits: Vec<(Document, f64)> = Vec::new();
let mut aggregations = None;
let mut query_params = Vec::new();
@@ -108,12 +148,54 @@ impl SearchSource for CocoSearchSource {
// Add query strings
for (key, value) in query.query_strings {
query_params.push(format!("{}={}", key, value));
if let Some(param) = convert_query_string(&key, &value) {
query_params.push(param);
}
}
let response = HttpClient::get(&self.server.id, &url, Some(query_params))
.await
.context(HttpSnafu)?;
let request_body = r#"
{
"aggs": {
"category": {
"terms": {
"field": "category"
}
},
"lang": {
"terms": {
"field": "lang"
}
},
"source.id": {
"terms": {
"field": "source.id"
},
"aggs": {
"top": {
"top_hits": {
"size": 1,
"_source": [
"source.name"
]
}
}
}
},
"type": {
"terms": {
"field": "type"
}
}
}
}"#;
let response = HttpClient::post(
&self.server.id,
url,
Some(query_params),
Some(request_body.into()),
)
.await
.context(HttpSnafu)?;
let status_code = response.status();
if ![StatusCode::OK, StatusCode::CREATED].contains(&status_code) {
@@ -156,6 +238,8 @@ impl SearchSource for CocoSearchSource {
hits.push((document, score));
}
}
aggregations = parsed.aggregations;
}
// Return the final result
@@ -163,6 +247,7 @@ impl SearchSource for CocoSearchSource {
source: self.get_type(),
hits,
total_hits,
aggregations,
})
}
}

View File

@@ -15,12 +15,12 @@ import { useConnectStore } from "@/stores/connectStore";
import FontIcon from "@/components/Common/Icons/FontIcon";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import NoDataImage from "@/components/Common/NoDataImage";
import PopoverInput from "@/components/Common/PopoverInput";
import { AssistantFetcher } from "./AssistantFetcher";
import AssistantItem from "./AssistantItem";
import Pagination from "@/components/Common/Pagination";
import { useSearchStore } from "@/stores/searchStore";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
interface AssistantListProps {
assistantIDs?: string[];
@@ -241,9 +241,10 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
searchInputRef.current?.focus();
}}
>
<PopoverInput
<Input
ref={searchInputRef}
autoFocus
autoCorrect="off"
value={keyword}
placeholder={t("assistant.popover.search")}
className="w-full h-8"

View File

@@ -63,6 +63,7 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
type="text"
id="endpoint"
value={endpointLink}
autoCorrect="off"
placeholder={t("cloud.connect.serverPlaceholder")}
onChange={onChangeEndpoint}
className="text-[#101010] dark:text-white flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800"

View File

@@ -2,6 +2,7 @@ import { User, LogOut } from "lucide-react";
import { UserProfile as UserInfo } from "@/types/server";
import { useState } from "react";
import { Button } from "../ui/button";
interface UserProfileProps {
server: string; //server's id
@@ -38,12 +39,14 @@ export function UserProfile({ server, userInfo, onLogout }: UserProfileProps) {
<span className="font-medium text-gray-900 dark:text-white">
{userInfo?.name || "-"}
</span>
<button
<Button
variant="outline"
size="icon"
onClick={handleLogout}
className="flex items-center p-1 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300 border border-[rgba(228,229,239,1)] dark:border-gray-700"
className="size-7 text-red-500!"
>
<LogOut className="w-4 h-4" />
</button>
<LogOut className="size-4" />
</Button>
</div>
<span className="text-sm text-gray-500 dark:text-gray-400">
{userInfo?.email || "-"}

View File

@@ -1,36 +0,0 @@
import type { InputProps } from "@/components/ui/input";
import { Input } from "@/components/ui/input";
import { useKeyPress } from "ahooks";
import { forwardRef, useImperativeHandle, useRef } from "react";
import { POPOVER_PANEL_SELECTOR } from "@/constants";
const PopoverInput = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => inputRef.current!);
useKeyPress(
"esc",
(event) => {
if (inputRef.current === document.activeElement) {
event.preventDefault();
event.stopPropagation();
inputRef.current?.blur();
const parentPanel = inputRef.current?.closest(POPOVER_PANEL_SELECTOR);
if (parentPanel instanceof HTMLElement) {
parentPanel.focus();
}
}
},
{
target: inputRef,
}
);
return <Input autoCorrect="off" ref={inputRef} {...(props as any)} />;
});
export default PopoverInput;

View File

@@ -1,9 +1,11 @@
import { FC, HTMLAttributes, useEffect, useRef, useState } from "react";
import { useKeyPress } from "ahooks";
import clsx from "clsx";
import { last } from "lodash-es";
import { POPOVER_PANEL_SELECTOR } from "@/constants";
import {
OPENED_POPOVER_TRIGGER_SELECTOR,
POPOVER_PANEL_SELECTOR,
} from "@/constants";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import { useAppStore } from "@/stores/appStore";
import { KeyType } from "ahooks/lib/useKeyPress";
@@ -43,22 +45,21 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
const [visibleShortcut, setVisibleShortcut] = useState<boolean>();
useEffect(() => {
const popoverPanelEls = document.querySelectorAll(POPOVER_PANEL_SELECTOR);
const popoverPanelEl = last(popoverPanelEls);
const popoverPanelEl = document.querySelector(POPOVER_PANEL_SELECTOR);
const openedPopoverTriggerEl = document.querySelector(
OPENED_POPOVER_TRIGGER_SELECTOR
);
if (!openPopover || !popoverPanelEl) {
return setVisibleShortcut(modifierKeyPressed);
}
const popoverButtonEl = document.querySelector(
`[aria-controls="${popoverPanelEl.id}"]`
const isChildInPanel = popoverPanelEl?.contains(childrenRef.current);
const isChildInTrigger = openedPopoverTriggerEl?.contains(
childrenRef.current
);
const isChildInPanel = popoverPanelEl?.contains(childrenRef.current);
const isChildInButton = popoverButtonEl?.contains(childrenRef.current);
const isChildInPopover = isChildInPanel || isChildInButton;
const isChildInPopover = isChildInPanel || isChildInTrigger;
setVisibleShortcut(isChildInPopover && modifierKeyPressed);
}, [openPopover, modifierKeyPressed]);
@@ -111,7 +112,7 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
{showTooltip && visibleShortcut ? (
<div
className={clsx(
"size-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
"size-4 flex items-center justify-center font-normal text-xs text-[#333] leading-3.5 bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
shortcutClassName
)}
>

View File

@@ -5,6 +5,7 @@ import clsx from "clsx";
import { useStreamChat } from "@/hooks/useStreamChat";
import { useExtensionsStore } from "@/stores/extensionsStore";
import { ChatMessage } from "../ChatMessage";
import { Button } from "../ui/button";
interface AiSummaryProps {
message: string;
@@ -33,20 +34,22 @@ const AiOverview: FC<AiSummaryProps> = (props) => {
<div className={clsx({ "p-2": visible })}>
<div
className={clsx(
"flex flex-col gap-2 relative max-h-[210px] px-4 py-3 rounded-[4px] text-[#333] dark:text-[#D8D8D8] bg-white dark:bg-[#141414] shadow-[0_4px_8px_rgba(0,0,0,0.1)] dark:shadow-[0_4px_20px_rgba(255,255,255,0.2)]",
"flex flex-col gap-2 relative max-h-[210px] px-4 py-3 rounded text-[#333] dark:text-[#D8D8D8] bg-white dark:bg-[#141414] shadow-[0_4px_8px_rgba(0,0,0,0.1)] dark:shadow-[0_4px_20px_rgba(255,255,255,0.2)]",
{
hidden: !visible,
}
)}
>
<div
className="absolute top-2 right-2 flex items-center justify-center size-[20px] border rounded-md cursor-pointer dark:border-[#282828]"
<Button
size="icon"
variant="outline"
className="absolute top-2 right-2 size-5"
onClick={() => {
setVisible(false);
}}
>
<X className="size-4" />
</div>
<X className="size-3" />
</Button>
<div className="flex item-center gap-1">
<Sparkles className="size-4 text-[#881c94]" />
@@ -77,7 +80,7 @@ const AiOverview: FC<AiSummaryProps> = (props) => {
</div>
<div
className={clsx("min-h-[20px]", {
className={clsx("min-h-5", {
hidden: isTyping,
})}
/>

View File

@@ -6,6 +6,7 @@ import { formatter } from "@/utils/index";
import CommonIcon from "@/components/Common/Icons/CommonIcon";
import defaultThumbnail from "@/assets/coconut-tree.png";
import { RichCategories } from "./ListRight";
import { formatDateToLocal } from "@/utils/date";
interface DocumentDetailProps {
document: any;
@@ -20,7 +21,7 @@ interface DetailItemProps {
const DetailItem: React.FC<DetailItemProps> = ({ label, value, icon }) => (
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5 border-t border-[rgba(238,240,243,1)] dark:border-[#272626] pt-2.5">
<div className="text-[rgba(153,153,153,1)] dark:text-[#666] min-w-[80px]">
<div className="text-[rgba(153,153,153,1)] dark:text-[#666] min-w-20">
{label}
</div>
<div
@@ -66,7 +67,12 @@ export const DocumentDetail: React.FC<DocumentDetailProps> = ({ document }) => {
/>
) : (
<CommonIcon
renderOrder={["special_icon", "item_icon", "connector_icon", "default_icon"]}
renderOrder={[
"special_icon",
"item_icon",
"connector_icon",
"default_icon",
]}
item={document}
itemIcon={document?.icon}
defaultIcon={File}
@@ -114,7 +120,7 @@ export const DocumentDetail: React.FC<DocumentDetailProps> = ({ document }) => {
<DetailItem
label={t("search.document.richCategories")}
value={
<div className="min-w-[160px] flex items-center justify-end w-full text-[12px] relative">
<div className="min-w-40 flex items-center justify-end w-full text-[12px] relative">
<RichCategories item={document} isSelected={false} />
</div>
}
@@ -148,7 +154,7 @@ export const DocumentDetail: React.FC<DocumentDetailProps> = ({ document }) => {
{document?.created && (
<DetailItem
label={t("search.document.createdAt")}
value={document.created}
value={formatDateToLocal(document.created)}
/>
)}
@@ -180,7 +186,7 @@ export const DocumentDetail: React.FC<DocumentDetailProps> = ({ document }) => {
{document?.updated && (
<DetailItem
label={t("search.document.updatedAt")}
value={document?.updated || "-"}
value={formatDateToLocal(document.updated)}
/>
)}

View File

@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect, useCallback } from "react";
import { useInfiniteScroll } from "ahooks";
import { useDebounce, useInfiniteScroll } from "ahooks";
import { useTranslation } from "react-i18next";
import { Data } from "ahooks/lib/useInfiniteScroll/types";
import { nanoid } from "nanoid";
@@ -15,6 +15,7 @@ import { useAppStore } from "@/stores/appStore";
import { useConnectStore } from "@/stores/connectStore";
import SearchEmpty from "../Common/SearchEmpty";
import Scrollbar from "@/components/Common/Scrollbar";
import { getQueryStrings, updateAggregations } from "@/utils";
interface DocumentListProps {
onSelectDocument: (id: string) => void;
@@ -55,6 +56,18 @@ export const DocumentList: React.FC<DocumentListProps> = ({
const loadingFromRef = useRef<number>(-1);
const querySourceTimeoutRef = useRef(querySourceTimeout);
const { searchDelay } = useConnectStore();
const debouncedInput = useDebounce(input, { wait: searchDelay });
const {
aggregateFilter,
filterDateRange,
fuzziness,
filterMultiSelectOpened,
} = useSearchStore();
useEffect(() => {
querySourceTimeoutRef.current = querySourceTimeout;
}, [querySourceTimeout]);
@@ -77,21 +90,24 @@ export const DocumentList: React.FC<DocumentListProps> = ({
const from = data?.list?.length || 0;
let queryStrings: any = {
query: input,
query: debouncedInput,
datasource: sourceData?.source?.id,
querysource: sourceData?.querySource?.id,
};
if (sourceData?.rich_categories) {
queryStrings = {
query: input,
query: debouncedInput,
rich_category: sourceData?.rich_categories[0]?.key,
};
}
if (sourceData?.main_extension_id) {
queryStrings.main_extension_id = sourceData?.main_extension_id
queryStrings.main_extension_id = sourceData?.main_extension_id;
}
queryStrings = getQueryStrings(queryStrings);
let response: any;
if (isTauri) {
response = await platformAdapter.commands("query_coco_fusion", {
@@ -149,6 +165,8 @@ export const DocumentList: React.FC<DocumentListProps> = ({
}));
}
updateAggregations(response);
return {
list: list,
hasMore: list.length === PAGE_SIZE && from + list.length < allTotal,
@@ -157,6 +175,12 @@ export const DocumentList: React.FC<DocumentListProps> = ({
const { loading } = useInfiniteScroll(
(data) => {
const { filterMultiSelectOpened } = useSearchStore.getState();
if (filterMultiSelectOpened) {
return Promise.resolve({ list: data?.list ?? [], hasMore: false });
}
// Prevent repeated requests for the same from value
const currentFrom = data?.list?.length || 0;
@@ -181,7 +205,14 @@ export const DocumentList: React.FC<DocumentListProps> = ({
{
target: containerRef,
isNoMore: (d) => !d?.hasMore,
reloadDeps: [input, JSON.stringify(sourceData)],
reloadDeps: [
debouncedInput,
JSON.stringify(sourceData),
aggregateFilter,
filterDateRange,
fuzziness,
filterMultiSelectOpened,
],
onFinally: (data) => {
if (data?.page === 1) return;
if (selectedItem === null) return;
@@ -205,16 +236,25 @@ export const DocumentList: React.FC<DocumentListProps> = ({
useEffect(() => {
setSelectedItem(null);
setIsKeyboardMode(false);
}, [isChatMode, input]);
}, [isChatMode, debouncedInput]);
useEffect(() => {
if (filterMultiSelectOpened) return;
setTotal(0);
setData((prev) => ({
...prev,
list: [],
}));
loadingFromRef.current = -1;
}, [input, JSON.stringify(sourceData)]);
}, [
debouncedInput,
JSON.stringify(sourceData),
aggregateFilter,
filterDateRange,
fuzziness,
filterMultiSelectOpened,
]);
const { visibleContextMenu } = useSearchStore();
@@ -309,10 +349,6 @@ export const DocumentList: React.FC<DocumentListProps> = ({
<Scrollbar className="flex-1 overflow-auto pr-0.5" ref={containerRef}>
{data?.list && data.list.length > 0 && (
<div>
{(() => {
console.log("Rendering list with items:", data.list.length);
return null;
})()}
{data.list.map((hit, index) => (
<SearchListItem
key={hit.document.id + index}

View File

@@ -1,5 +1,4 @@
import { Button } from "@/components/ui/button";
import dayjs from "dayjs";
import {
CircleCheck,
Download,
@@ -17,6 +16,7 @@ import { useSearchStore } from "@/stores/searchStore";
import DeleteDialog from "../Common/DeleteDialog";
import PreviewImage from "../Common/PreviewImage";
import platformAdapter from "@/utils/platformAdapter";
import { formatDateToLocal } from "@/utils/date";
interface ExtensionDetailProps {
onInstall: () => void;
@@ -226,7 +226,7 @@ const ExtensionDetail: FC<ExtensionDetailProps> = (props) => {
{t("extensionDetail.label.lastUpdate")}
</div>
<p>
{dayjs(selectedExtension.updated).format("YYYY-MM-DD HH:mm:ss")}
{formatDateToLocal(selectedExtension.updated)}
</p>
</div>
@@ -243,7 +243,7 @@ const ExtensionDetail: FC<ExtensionDetailProps> = (props) => {
}}
deleteButtonProps={{
className:
"!text-[#FF4949] bg-[#F8F9FA] dark:text-white dark:bg-[#202126] border-[#E6E6E6] dark:border-white/10",
"text-white bg-[#FF4949] hover:bg-[#FF4949] border-[#E6E6E6] dark:border-white/10",
}}
setIsOpen={setIsOpen}
onCancel={handleCancel}

View File

@@ -332,7 +332,7 @@ const ExtensionStore = ({
}}
>
<div className="flex items-center gap-2 overflow-hidden">
<img src={icon} className="size-[20px]" />
<img src={icon} className="size-5" />
<span className="whitespace-nowrap">{name}</span>
<span className="truncate text-[#999]">{description}</span>
</div>

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo } from "react";
import { Brain, Sparkles } from "lucide-react";
import { Brain, RotateCcw, ScanSearch, Sparkles } from "lucide-react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
@@ -12,11 +12,13 @@ import { useConnectStore } from "@/stores/connectStore";
import VisibleKey from "@/components/Common/VisibleKey";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore";
import { DEFAULT_FUZZINESS, useSearchStore } from "@/stores/searchStore";
import { useExtensionsStore } from "@/stores/extensionsStore";
import { parseSearchQuery, SearchQuery, canNavigateBack } from "@/utils";
import InputUpload from "./InputUpload";
import Copyright from "../Common/Copyright";
import TimeFilter from "./TimeFilter";
import { Slider } from "../ui/slider";
interface InputControlsProps {
isChatMode: boolean;
@@ -161,7 +163,13 @@ const InputControls = ({
return state.aiOverviewAssistant;
});
const aiOverviewShortcut = useShortcutsStore((state) => state.aiOverview);
const { visibleExtensionStore } = useSearchStore();
const {
visibleExtensionStore,
enabledFuzzyMatch,
setEnabledFuzzyMatch,
fuzziness,
setFuzziness,
} = useSearchStore();
return (
<div
@@ -242,7 +250,10 @@ const InputControls = ({
)}
</div>
) : (
<div data-tauri-drag-region className="w-28 flex gap-2 relative">
<div
data-tauri-drag-region
className="w-28 flex gap-2 items-center relative"
>
{!disabledExtensions.includes("AIOverview") &&
isTauri &&
aiOverviewServer &&
@@ -274,12 +285,74 @@ const InputControls = ({
</VisibleKey>
<span
className={clsx("text-xs", { hidden: !enabledAiOverview })}
className={clsx("text-xs truncate", {
hidden: !enabledAiOverview,
})}
>
AI Overview
</span>
</div>
)}
{/* app search filter */}
{isTauri && (
<>
<div
className={clsx(
"inline-flex items-center gap-1 h-5 px-1 rounded-full hover:text-[#881c94]! cursor-pointer transition",
[
enabledFuzzyMatch
? "text-[#881c94]"
: "text-[#333] dark:text-[#d8d8d8]",
],
{
"bg-[#881C94]/20 dark:bg-[#202126]": enabledFuzzyMatch,
}
)}
onClick={() => {
setEnabledFuzzyMatch(!enabledFuzzyMatch);
}}
>
<ScanSearch className="size-3" />
{enabledFuzzyMatch && (
<>
<span className={clsx("text-xs truncate")}>
{t("search.fuzziness.fuzzyMatch")}
</span>
<Slider
value={[fuzziness]}
max={5}
className="w-20"
classNames={{
range: "bg-[#881C94]",
thumb:
"border-[#881C94] focus-visible:ring-0 focus-visible:ring-offset-0",
}}
onValueChange={(value) => {
setFuzziness(value[0]);
}}
onClick={(event) => {
event.stopPropagation();
}}
/>
<RotateCcw
className="size-3"
onClick={(event) => {
event.stopPropagation();
setFuzziness(DEFAULT_FUZZINESS);
}}
/>
</>
)}
</div>
<TimeFilter />
</>
)}
</div>
)}

View File

@@ -17,10 +17,10 @@ import Checkbox from "@/components/Common/Checkbox";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import VisibleKey from "@/components/Common/VisibleKey";
import NoDataImage from "@/components/Common/NoDataImage";
import PopoverInput from "@/components/Common/PopoverInput";
import Pagination from "@/components/Common/Pagination";
import { SearchQuery } from "@/utils";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
interface MCPPopoverProps {
mcp_servers: any;
@@ -263,8 +263,9 @@ export default function MCPPopover({
/>
</div>
<PopoverInput
<Input
autoFocus
autoCorrect="off"
value={keyword}
ref={searchInputRef}
className="size-full px-2 rounded-lg border dark:border-white/10 bg-transparent"

View File

@@ -17,9 +17,9 @@ import Checkbox from "@/components/Common/Checkbox";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import VisibleKey from "@/components/Common/VisibleKey";
import NoDataImage from "@/components/Common/NoDataImage";
import PopoverInput from "@/components/Common/PopoverInput";
import Pagination from "@/components/Common/Pagination";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
interface SearchPopoverProps {
datasource: any;
@@ -271,8 +271,9 @@ export default function SearchPopover({
/>
</div>
<PopoverInput
<Input
autoFocus
autoCorrect="off"
value={keyword}
ref={searchInputRef}
className="size-full px-2 rounded-lg border dark:border-white/10 bg-transparent"

View File

@@ -0,0 +1,242 @@
import { useState, Fragment, useMemo } from "react";
import { ListFilter, ChevronRight, BrushCleaning, Check } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { useSearchStore } from "@/stores/searchStore";
import MultiSelect from "../ui/multi-select";
import DatePickerRange from "../ui/date-picker-range";
import { camelCase, cloneDeep, differenceBy, upperFirst } from "lodash-es";
import dayjs from "dayjs";
import { cn } from "@/lib/utils";
import { Button } from "../ui/button";
import { useTranslation } from "react-i18next";
const TimeFilter = () => {
const [dropdownOpen, setDropdownOpen] = useState(false);
const [popoverOpen, setPopoverOpen] = useState(false);
const {
filterDateRange,
setFilterDateRange,
aggregateFilter,
setAggregateFilter,
aggregations,
setFilterMultiSelectOpened,
} = useSearchStore();
const { t } = useTranslation();
const dropdownMenuItems = useMemo(() => {
return [
{
key: "all-time",
label: t("search.filters.allTime"),
value: void 0,
},
{
key: "7-day",
label: t("search.filters.past7Days"),
value: {
from: dayjs().subtract(7, "day").toDate(),
to: dayjs().toDate(),
},
},
{
key: "90-day",
label: t("search.filters.past90Days"),
value: {
from: dayjs().subtract(90, "day").toDate(),
to: dayjs().toDate(),
},
},
{
key: "1-year",
label: t("search.filters.past1year"),
value: {
from: dayjs().subtract(1, "year").toDate(),
to: dayjs().toDate(),
},
},
{
key: "more",
label: t("search.filters.more"),
onClick: () => {
setPopoverOpen(true);
},
},
];
}, [t]);
const filterCount = useMemo(() => {
let count = 0;
if (filterDateRange) {
count += 1;
}
if (aggregateFilter) {
for (const item of Object.values(aggregateFilter)) {
if (item.length === 0) continue;
count += 1;
}
}
return count;
}, [filterDateRange, aggregateFilter]);
return (
<div>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<div
className={cn(
"inline-flex items-center gap-1 h-5 px-1 text-xs rounded-full hover:text-[#881c94]! cursor-pointer transition",
{
"bg-[#881C94]/20 dark:bg-[#202126] text-[#881c94]":
filterCount > 0,
}
)}
>
<ListFilter className="size-3" />
{filterCount > 0 && (
<>
<div className="whitespace-nowrap">
{t("search.filters.filters")}
</div>
<div className="inline-flex items-center justify-center size-4 rounded-full text-white bg-[#881c94]">
{filterCount}
</div>
</>
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
{dropdownMenuItems.map((item) => {
const { key, label, value, onClick } = item;
const isSame =
dayjs(filterDateRange?.from).isSame(dayjs(value?.from), "day") &&
dayjs(filterDateRange?.to).isSame(dayjs(value?.to), "day");
return (
<DropdownMenuItem
key={key}
className={cn("flex justify-between")}
onClick={() => {
if (onClick) {
onClick();
} else {
setFilterDateRange(value);
}
}}
>
<span>{label}</span>
{key === "more" ? (
<ChevronRight className="size-4 text-muted-foreground" />
) : (
<Check
className={cn("size-4 text-muted-foreground opacity-0", {
"opacity-100": isSame,
})}
/>
)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<div />
</PopoverTrigger>
<PopoverContent className="w-100 max-h-110 overflow-auto p-4 text-sm">
<div className="flex items-center justify-between text-sm">
<span className="font-bold">{t("search.filters.filters")}</span>
<Button
size="icon"
variant="outline"
className="size-6"
onClick={() => {
setFilterDateRange(void 0);
setAggregateFilter(void 0);
}}
>
<BrushCleaning className="size-3 text-[#6000FF]" />
</Button>
</div>
<div className="pt-4 pb-2 text-[#999]">
{t("search.filters.updateTime")}
</div>
<DatePickerRange
selected={filterDateRange}
onSelect={setFilterDateRange}
/>
{aggregations &&
Object.entries(aggregations).map(([key, value], index) => {
let selectedValue = aggregateFilter?.[key] ?? [];
const buckets = cloneDeep(value.buckets);
if (selectedValue.length > 0) {
const missingBuckets = differenceBy(
selectedValue,
buckets,
"key"
);
buckets.push(...missingBuckets);
}
return (
<Fragment key={key}>
<div className="pt-4 pb-2 text-[#999]">
{upperFirst(camelCase(key))}
</div>
<MultiSelect
value={selectedValue.map((item) => item.key)}
placeholder={`Please select ${key}`}
options={buckets.map((bucket) => ({
label: bucket.label,
value: bucket.key,
}))}
dropdownMenuContent={{
className: "max-h-60 overflow-auto",
side: index > 2 ? "top" : void 0,
}}
onChange={(value) => {
const data = buckets.filter((bucket) => {
return value.includes(bucket.key);
});
setAggregateFilter({
...aggregateFilter,
[key]: data,
});
}}
onOpenChange={setFilterMultiSelectOpened}
/>
</Fragment>
);
})}
</PopoverContent>
</Popover>
</div>
);
};
export default TimeFilter;

View File

@@ -1,5 +1,5 @@
import React from "react";
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Maximize2, Minimize2, Focus } from "lucide-react";
@@ -7,41 +7,18 @@ import { useSearchStore } from "@/stores/searchStore";
import {
ExtensionFileSystemPermission,
FileSystemAccess,
ViewExtensionUISettings,
} from "../Settings/Extensions";
import platformAdapter from "@/utils/platformAdapter";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import { isMac } from "@/utils/platform";
import { useAppStore } from "@/stores/appStore";
import { useViewExtensionWindow } from "@/hooks/useViewExtensionWindow";
const ViewExtension: React.FC = () => {
const { viewExtensionOpened } = useSearchStore();
const isTauri = useAppStore((state) => state.isTauri);
// Complete list of the backend APIs, grouped by their category.
const [apis, setApis] = useState<Map<string, string[]> | null>(null);
const { setModifierKeyPressed } = useShortcutsStore();
const { t } = useTranslation();
const [isFullscreen, setIsFullscreen] = useState(false);
const prevWindowRef = useRef<{
width: number;
height: number;
resizable: boolean;
x: number;
y: number;
} | null>(null);
const fullscreenPrevRef = useRef<{
width: number;
height: number;
resizable: boolean;
x: number;
y: number;
} | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const DEFAULT_VIEW_WIDTH = 1200;
const DEFAULT_VIEW_HEIGHT = 900;
const [scale, setScale] = useState(1);
if (viewExtensionOpened == null) {
// When this view gets loaded, this state should not be NULL.
@@ -191,158 +168,18 @@ const ViewExtension: React.FC = () => {
}, [reversedApis, permission]); // Add apiPermissions as dependency
const fileUrl = viewExtensionOpened[2];
const ui: ViewExtensionUISettings | undefined = useMemo(() => {
return viewExtensionOpened[4] as ViewExtensionUISettings | undefined;
}, [viewExtensionOpened]);
const resizable = ui?.resizable;
const baseWidth = useMemo(() => {
return ui && typeof ui?.width === "number" ? ui.width : DEFAULT_VIEW_WIDTH;
}, [ui]);
const baseHeight = useMemo(() => {
return ui && typeof ui?.height === "number" ? ui.height : DEFAULT_VIEW_HEIGHT;
}, [ui]);
const recomputeScale = useCallback(async () => {
const size = await platformAdapter.getWindowSize();
const nextScale = Math.min(size.width / baseWidth, size.height / baseHeight);
setScale(Math.max(nextScale, 0.1));
}, [baseWidth, baseHeight]);
const applyFullscreen = useCallback(
async (next: boolean) => {
if (next) {
const size = await platformAdapter.getWindowSize();
const resizable = await platformAdapter.isWindowResizable();
const pos = await platformAdapter.getWindowPosition();
fullscreenPrevRef.current = {
width: size.width,
height: size.height,
resizable,
x: pos.x,
y: pos.y,
};
if (isMac && isTauri) {
const monitor = await platformAdapter.getMonitorFromCursor();
if (!monitor) return;
const window = await platformAdapter.getCurrentWebviewWindow();
const factor = await window.scaleFactor();
const { size, position } = monitor;
const { width, height } = size.toLogical(factor);
const { x, y } = position.toLogical(factor);
await platformAdapter.setWindowSize(width, height);
await platformAdapter.setWindowPosition(x, y);
await platformAdapter.setWindowResizable(true);
await recomputeScale();
} else {
await platformAdapter.setWindowFullscreen(true);
await recomputeScale();
}
} else {
if (!isMac) {
await platformAdapter.setWindowFullscreen(false);
}
const nextWidth =
ui && typeof ui.width === "number" ? ui.width : DEFAULT_VIEW_WIDTH;
const nextHeight =
ui && typeof ui.height === "number" ? ui.height : DEFAULT_VIEW_HEIGHT;
const nextResizable =
ui && typeof ui.resizable === "boolean" ? ui.resizable : true;
await platformAdapter.setWindowSize(nextWidth, nextHeight);
await platformAdapter.setWindowResizable(nextResizable);
await platformAdapter.centerOnCurrentMonitor();
await recomputeScale();
setTimeout(() => {
iframeRef.current?.focus();
try {
iframeRef.current?.contentWindow?.focus();
} catch {}
}, 0);
}
},
[ui, recomputeScale]
);
useEffect(() => {
const applyWindowSettings = async () => {
if (viewExtensionOpened != null) {
const size = await platformAdapter.getWindowSize();
const resizable = await platformAdapter.isWindowResizable();
const pos = await platformAdapter.getWindowPosition();
prevWindowRef.current = {
width: size.width,
height: size.height,
resizable,
x: pos.x,
y: pos.y,
};
const nextWidth =
ui && typeof ui.width === "number" ? ui.width : DEFAULT_VIEW_WIDTH;
const nextHeight =
ui && typeof ui.height === "number" ? ui.height : DEFAULT_VIEW_HEIGHT;
const nextResizable =
ui && typeof ui.resizable === "boolean" ? ui.resizable : true;
await platformAdapter.setWindowSize(nextWidth, nextHeight);
await platformAdapter.setWindowResizable(nextResizable);
await platformAdapter.centerOnCurrentMonitor();
await recomputeScale();
setTimeout(() => {
iframeRef.current?.focus();
try {
iframeRef.current?.contentWindow?.focus();
} catch {}
}, 0);
} else {
if (prevWindowRef.current) {
const prev = prevWindowRef.current;
await platformAdapter.setWindowSize(prev.width, prev.height);
await platformAdapter.setWindowResizable(prev.resizable);
await platformAdapter.centerOnCurrentMonitor();
prevWindowRef.current = null;
await recomputeScale();
setTimeout(() => {
iframeRef.current?.focus();
}, 0);
}
}
};
applyWindowSettings();
return () => {
if (prevWindowRef.current) {
const prev = prevWindowRef.current;
platformAdapter.setWindowSize(prev.width, prev.height);
platformAdapter.setWindowResizable(prev.resizable);
platformAdapter.centerOnCurrentMonitor();
prevWindowRef.current = null;
}
};
}, [viewExtensionOpened]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isFullscreen) {
applyFullscreen(false);
setIsFullscreen(false);
}
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
return () => {
window.removeEventListener("keydown", handleKeyDown, {
capture: true,
} as any);
};
}, [isFullscreen, applyFullscreen]);
const {
resizable,
scale,
iframeRef,
isFullscreen,
toggleFullscreen,
focusIframe,
} = useViewExtensionWindow();
return (
<div className="relative w-full h-full">
{isFullscreen && <div className="absolute inset-0 pointer-events-none" />}
{resizable && (
<button
aria-label={
@@ -351,17 +188,7 @@ const ViewExtension: React.FC = () => {
: t("viewExtension.fullscreen.enter")
}
className="absolute top-2 right-2 z-10 rounded-md bg-black/40 text-white p-2 hover:bg-black/60 focus:outline-none"
onClick={async () => {
const next = !isFullscreen;
await applyFullscreen(next);
setIsFullscreen(next);
if (next) {
iframeRef.current?.focus();
try {
iframeRef.current?.contentWindow?.focus();
} catch {}
}
}}
onClick={toggleFullscreen}
>
{isFullscreen ? (
<Minimize2 className="size-4" />
@@ -371,37 +198,26 @@ const ViewExtension: React.FC = () => {
</button>
)}
{/* Focus helper button */}
<button
aria-label={t("viewExtension.focus")}
className="absolute top-2 right-12 z-10 rounded-md bg-black/40 text-white p-2 hover:bg-black/60 focus:outline-none"
onClick={() => {
iframeRef.current?.focus();
try {
iframeRef.current?.contentWindow?.focus();
} catch {}
}}
>
<Focus className="size-4"/>
</button>
{resizable && (
<button
aria-label={t("viewExtension.focus")}
className="absolute top-2 right-12 z-10 rounded-md bg-black/40 text-white p-2 hover:bg-black/60 focus:outline-none"
onClick={focusIframe}
>
<Focus className="size-4" />
</button>
)}
<div
className="w-full h-full flex items-center justify-center"
onMouseDownCapture={() => {
iframeRef.current?.focus();
}}
onPointerDown={() => {
iframeRef.current?.focus();
}}
onClickCapture={() => {
iframeRef.current?.focus();
}}
className="w-full h-full flex items-center justify-center overflow-hidden"
onMouseDownCapture={focusIframe}
onPointerDown={focusIframe}
onClickCapture={focusIframe}
>
<iframe
ref={iframeRef}
src={fileUrl}
className="border-0"
className="border-0 w-full h-full"
style={{
width: `${baseWidth}px`,
height: `${baseHeight}px`,
transform: `scale(${scale})`,
transformOrigin: "center center",
outline: "none",

View File

@@ -182,12 +182,9 @@ function SearchChat({
});
useEffect(() => {
const unlisten = platformAdapter.listenEvent(
"refresh-window-size",
() => {
debouncedSetWindowSize();
}
);
const unlisten = platformAdapter.listenEvent("refresh-window-size", () => {
debouncedSetWindowSize();
});
return () => {
unlisten
.then((fn) => {

View File

@@ -21,8 +21,8 @@ import SettingsInput from "@/components//Settings/SettingsInput";
import platformAdapter from "@/utils/platformAdapter";
import UpdateSettings from "./components/UpdateSettings";
import SettingsToggle from "../SettingsToggle";
import SelectionSettings from "./components/Selection";
import { isMac } from "@/utils/platform";
// import SelectionSettings from "./components/Selection";
// import { isMac } from "@/utils/platform";
import {
Select,
SelectTrigger,
@@ -196,7 +196,7 @@ const Advanced = () => {
})}
</div>
{isMac && <SelectionSettings />}
{/* {isMac && <SelectionSettings />} */}
<Shortcuts />

View File

@@ -1,11 +1,11 @@
import { useContext, useMemo, useState } from "react";
import dayjs from "dayjs";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "ahooks";
import platformAdapter from "@/utils/platformAdapter";
import { ExtensionsContext } from "../../../index";
import { filesize } from "@/utils";
import { formatDateToLocal } from "@/utils/date";
interface Metadata {
name: string;
@@ -62,15 +62,15 @@ const App = () => {
},
{
label: t("settings.extensions.application.details.created"),
value: dayjs(created).format("YYYY/MM/DD HH:mm:ss"),
value: formatDateToLocal(created),
},
{
label: t("settings.extensions.application.details.modified"),
value: dayjs(modified).format("YYYY/MM/DD HH:mm:ss"),
value: formatDateToLocal(modified),
},
{
label: t("settings.extensions.application.details.lastOpened"),
value: dayjs(lastOpened).format("YYYY/MM/DD HH:mm:ss"),
value: formatDateToLocal(lastOpened),
},
];
}, [appMetadata]);

View File

@@ -81,6 +81,8 @@ export interface ViewExtensionUISettings {
detachable: boolean;
}
export type ViewExtensionUISettingsOrNull = ViewExtensionUISettings | null | undefined;
export interface Extension {
id: ExtensionId;
type: ExtensionType;

View File

@@ -0,0 +1,212 @@
import * as React from "react";
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react";
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
style={{
// @ts-ignore
"--cell-size": "2rem",
}}
className={cn(
"bg-background group/calendar p-3 [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn("relative flex gap-4", defaultClassNames.months),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"h-[var(--cell-size)] w-[var(--cell-size)] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"h-[var(--cell-size)] w-[var(--cell-size)] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-[var(--cell-size)] w-full items-center justify-center px-[var(--cell-size)]",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-[var(--cell-size)] w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
defaultClassNames.dropdown_root
),
dropdown: cn(
"bg-popover absolute inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-[var(--cell-size)] select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-muted-foreground select-none text-[0.8rem]",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day
),
range_start: cn(
"bg-accent rounded-l-md",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
);
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
);
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
);
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-[var(--cell-size)] items-center justify-center text-center">
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[var(--cell-size)] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };

View File

@@ -0,0 +1,43 @@
import { FC, memo } from "react";
import { PropsRange } from "react-day-picker";
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
import { Calendar } from "./calendar";
import { CalendarIcon } from "lucide-react";
import dayjs from "dayjs";
import { useTranslation } from "react-i18next";
const DatePickerRange: FC<Partial<PropsRange>> = (props) => {
const { selected } = props;
const { t } = useTranslation();
return (
<Popover>
<PopoverTrigger asChild>
<div className="h-8 flex items-center justify-between px-2 border border-border rounded-lg">
{selected ? (
<div className="flex items-center gap-2">
<span>{dayjs(selected.from).format("YYYY-MM-DD")}</span>
<span className="text-muted-foreground">-</span>
<span>{dayjs(selected.to).format("YYYY-MM-DD")}</span>
</div>
) : (
<div className="text-muted-foreground">
{t("search.filters.selectDateRange")}
</div>
)}
<CalendarIcon className="size-4 text-muted-foreground" />
</div>
</PopoverTrigger>
<PopoverContent>
<div>
<Calendar mode="range" numberOfMonths={2} {...props} />
</div>
</PopoverContent>
</Popover>
);
};
export default memo(DatePickerRange);

View File

@@ -1,97 +1,129 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { FC, useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "./dropdown-menu";
import { Check, ChevronDown, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Checkbox } from "@/components/ui/checkbox";
import { DropdownMenuContentProps } from "@radix-ui/react-dropdown-menu";
type Option = { value: string; label: string };
export interface MultiSelectProps {
options: Option[];
value: string[];
onChange?: (next: string[]) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
export interface Option {
label: string;
value: string;
}
export const MultiSelect: React.FC<MultiSelectProps> = ({
options,
value,
onChange,
placeholder = "",
className,
disabled,
}) => {
const [open, setOpen] = React.useState(false);
const values = React.useMemo(() => new Set(value), [value]);
export interface MultiSelectProps {
value: string[];
options: Option[];
placeholder?: string;
dropdownMenuContent?: DropdownMenuContentProps;
onChange?: (value: string[]) => void;
onOpenChange?: (open: boolean) => void;
}
const toggle = (v: string) => {
const next = new Set(values);
if (next.has(v)) next.delete(v);
else next.add(v);
onChange?.(Array.from(next));
const MultiSelect: FC<MultiSelectProps> = (props) => {
const {
value,
options,
placeholder,
dropdownMenuContent,
onChange,
onOpenChange,
} = props;
const [open, setOpen] = useState(false);
const handleRemove = (item: string) => {
onChange?.(value.filter((i) => i !== item));
};
const display = React.useMemo(() => {
if (values.size === 0) return placeholder;
const labels = options
.filter((o) => values.has(o.value))
.map((o) => o.label);
return labels.join(", ");
}, [options, values, placeholder]);
const renderTrigger = () => {
if (value.length === 0) {
return <div className="text-muted-foreground px-1">{placeholder}</div>;
}
return (
<div className="flex flex-wrap gap-1">
{value.map((item) => (
<div
key={item}
className="inline-flex items-center gap-1 h-5.5 px-2 bg-muted rounded-md text-muted-foreground"
>
<span>
{options.find((option) => option.value === item)?.label ?? value}
</span>
<X
className="size-3 text-muted-foreground hover:text-red-500 transition cursor-pointer"
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
if (event.button === 0) {
handleRemove(item);
}
}}
/>
</div>
))}
</div>
);
};
return (
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
<PopoverPrimitive.Trigger asChild>
<button
type="button"
disabled={disabled}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
>
<span className={cn(values.size === 0 && "text-muted-foreground")}>{display}</span>
<svg
className="h-4 w-4 opacity-70"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Content
sideOffset={4}
className={cn(
"z-50 w-(--radix-popover-trigger-width) min-w-[220px] rounded-md border border-input bg-popover p-2 text-popover-foreground shadow-md outline-none",
"ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
)}
>
<div className="max-h-48 overflow-y-auto space-y-1">
{options.map((opt) => {
const checked = values.has(opt.value) ? "checked" : "unchecked";
return (
<label
key={opt.value}
className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent hover:text-accent-foreground"
onClick={(e) => {
e.preventDefault();
toggle(opt.value);
}}
>
<Checkbox checked={checked === "checked"} className="h-4 w-4" />
<span className="text-sm">{opt.label}</span>
</label>
);
})}
<DropdownMenu
onOpenChange={(open) => {
setOpen(open);
onOpenChange?.(open);
}}
>
<DropdownMenuTrigger asChild>
<div className="flex items-center justify-between border border-border min-h-8 rounded-lg p-1">
{renderTrigger()}
<ChevronDown
className={cn("size-4 min-w-4 text-muted-foreground transition", {
"rotate-180": open,
})}
/>
</div>
</PopoverPrimitive.Content>
</PopoverPrimitive.Root>
</DropdownMenuTrigger>
<DropdownMenuContent {...dropdownMenuContent}>
{options.map((item) => {
const { label, value: itemValue } = item;
const included = value.includes(itemValue);
return (
<DropdownMenuItem
key={itemValue}
className={"flex items-center justify-between gap-2"}
onSelect={(event) => {
event.preventDefault();
if (included) {
onChange?.(value.filter((item) => item !== itemValue));
} else {
onChange?.([...value, itemValue]);
}
}}
>
<span>{label}</span>
<Check
className={cn("size-4 text-muted-foreground", {
"opacity-0": !value.includes(itemValue),
})}
/>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
};
export default MultiSelect;

View File

@@ -1,6 +1,7 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
import { OPENED_POPOVER_TRIGGER_SELECTOR } from "@/constants";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
@@ -34,6 +35,24 @@ const PopoverContent = React.forwardRef<
)}
data-popover-panel
id={panelId}
onEscapeKeyDown={(event) => {
event.stopPropagation();
event.preventDefault();
if (
document.activeElement instanceof HTMLInputElement ||
document.activeElement instanceof HTMLTextAreaElement
) {
return document.activeElement.blur();
}
const el = document.querySelector(OPENED_POPOVER_TRIGGER_SELECTOR);
if (el instanceof HTMLElement) {
el.click();
}
}}
{...props}
/>
)

View File

@@ -2,10 +2,18 @@ import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
interface SliderRangeProps
extends React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> {
classNames?: {
range?: string;
thumb?: string;
};
}
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
SliderRangeProps
>(({ className, classNames, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
@@ -15,13 +23,19 @@ const Slider = React.forwardRef<
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
<SliderPrimitive.Range
className={cn("absolute h-full bg-primary", classNames?.range)}
/>
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb
className={cn(
"block h-4 w-4 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
classNames?.thumb
)}
/>
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@@ -1,8 +1,11 @@
export const POPOVER_PANEL_SELECTOR = '[data-popover-panel]';
export const POPOVER_PANEL_SELECTOR = "[data-radix-popper-content-wrapper]";
export const HISTORY_PANEL_ID = "headlessui-popover-panel:history-panel";
export const OPENED_POPOVER_TRIGGER_SELECTOR =
"[aria-haspopup='dialog'][aria-expanded='true'][data-state='open']";
export const CONTEXT_MENU_PANEL_ID = "headlessui-popover-panel:context-menu";
export const HISTORY_PANEL_ID = "popover-panel:history-panel";
export const CONTEXT_MENU_PANEL_ID = "popover-panel:context-menu";
export const DEFAULT_COCO_SERVER_ID = "default_coco_server";

View File

@@ -0,0 +1,30 @@
import { useSearchStore } from "@/stores/searchStore";
import { useMemo } from "react";
export const useCanNavigateBack = () => {
const {
goAskAi,
visibleExtensionStore,
visibleExtensionDetail,
viewExtensionOpened,
sourceData,
} = useSearchStore();
const canNavigateBack = useMemo(() => {
return (
goAskAi ||
visibleExtensionStore ||
visibleExtensionDetail ||
viewExtensionOpened ||
sourceData
);
}, [
goAskAi,
visibleExtensionStore,
visibleExtensionDetail,
viewExtensionOpened,
sourceData,
]);
return { canNavigateBack };
};

View File

@@ -5,20 +5,15 @@ import { HISTORY_PANEL_ID } from "@/constants";
import { closeHistoryPanel } from "@/utils";
const useEscape = () => {
const visibleContextMenu = useSearchStore((state) => {
return state.visibleContextMenu;
});
const setVisibleContextMenu = useSearchStore((state) => {
return state.setVisibleContextMenu;
});
const viewExtensionOpened = useSearchStore((state) => {
return state.viewExtensionOpened;
});
const { setVisibleContextMenu } = useSearchStore();
useKeyPress("esc", (event) => {
event.preventDefault();
event.stopPropagation();
const { visibleContextMenu, viewExtensionOpened } =
useSearchStore.getState();
if (
document.activeElement instanceof HTMLInputElement ||
document.activeElement instanceof HTMLTextAreaElement
@@ -39,6 +34,7 @@ const useEscape = () => {
if (viewExtensionOpened != null) {
return;
}
platformAdapter.hideWindow();
});
};

View File

@@ -16,8 +16,9 @@ export const useModifierKeyPress = () => {
useKeyPress(
modifierKey,
(event) => {
const popoverPanelEl = document.querySelector(POPOVER_PANEL_SELECTOR);
setOpenPopover(Boolean(popoverPanelEl));
const el = document.querySelector(POPOVER_PANEL_SELECTOR);
setOpenPopover(Boolean(el));
setModifierKeyPressed(event.type === "keydown");
},

View File

@@ -1,5 +1,9 @@
import { useState, useCallback, useMemo, useRef } from "react";
import { debounce, orderBy } from "lodash-es";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import advancedFormat from "dayjs/plugin/advancedFormat";
import type {
QueryHits,
@@ -13,6 +17,12 @@ import { useConnectStore } from "@/stores/connectStore";
import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore";
import { useExtensionsStore } from "@/stores/extensionsStore";
import { getQueryStrings, updateAggregations } from "@/utils";
import { useCanNavigateBack } from "./useCanNavigateBack";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(advancedFormat);
interface SearchState {
isError: FailedRequest[];
@@ -52,6 +62,12 @@ export function useSearch() {
});
const { querySourceTimeout, searchDelay } = useConnectStore();
const {
aggregateFilter,
filterDateRange,
fuzziness,
filterMultiSelectOpened,
} = useSearchStore();
const [searchState, setSearchState] = useState<SearchState>({
isError: [],
@@ -157,19 +173,34 @@ export function useSearch() {
});
};
const { canNavigateBack } = useCanNavigateBack();
const performSearch = useCallback(
async (searchInput: string) => {
const { filterMultiSelectOpened } = useSearchStore.getState();
if (filterMultiSelectOpened || canNavigateBack) return;
if (!searchInput) {
setSearchState((prev) => ({ ...prev, suggests: [] }));
return;
const { setAggregations, setAggregateFilter } =
useSearchStore.getState();
setAggregations(void 0);
setAggregateFilter(void 0);
return setSearchState((prev) => ({ ...prev, suggests: [] }));
}
let response: MultiSourceQueryResponse;
if (isTauri) {
const queryStrings = getQueryStrings({
query: searchInput,
});
response = await platformAdapter.commands("query_coco_fusion", {
from: 0,
size: 10,
queryStrings: { query: searchInput },
queryStrings,
queryTimeout: querySourceTimeout,
});
} else {
@@ -198,7 +229,9 @@ export function useSearch() {
}
}
//console.log("_suggest", searchInput, response);
// console.log("_suggest", searchInput, response);
updateAggregations(response);
if (timerRef.current) {
clearTimeout(timerRef.current);
@@ -216,6 +249,11 @@ export function useSearch() {
aiOverviewCharLen,
aiOverviewDelay,
aiOverviewMinQuantity,
aggregateFilter,
filterDateRange,
fuzziness,
filterMultiSelectOpened,
canNavigateBack,
]
);

View File

@@ -0,0 +1,243 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import platformAdapter from "@/utils/platformAdapter";
import { isMac } from "@/utils/platform";
import type { ViewExtensionUISettingsOrNull } from "@/components/Settings/Extensions";
import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore";
type WindowSnapshot = {
width: number;
height: number;
resizable: boolean;
x: number;
y: number;
};
export function useViewExtensionWindow() {
const isTauri = useAppStore((state) => state.isTauri);
const viewExtensionOpened = useSearchStore((state) => state.viewExtensionOpened);
if (viewExtensionOpened == null) {
throw new Error(
"ViewExtension Error: viewExtensionOpened is null. This should not happen."
);
}
const ui: ViewExtensionUISettingsOrNull = useMemo(() => {
return viewExtensionOpened[4] as ViewExtensionUISettingsOrNull;
}, [viewExtensionOpened]);
const resizable = ui?.resizable;
const uiWidth = ui && typeof ui.width === "number" ? ui.width : null;
const uiHeight = ui && typeof ui.height === "number" ? ui.height : null;
const hasExplicitWindowSize = uiWidth != null && uiHeight != null;
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const prevWindowRef = useRef<WindowSnapshot | null>(null);
const fullscreenPrevRef = useRef<WindowSnapshot | null>(null);
const [scale, setScale] = useState(1);
const [fallbackViewSize, setFallbackViewSize] = useState<{
width: number;
height: number;
} | null>(() => {
if (typeof window === "undefined") return null;
return { width: window.innerWidth, height: window.innerHeight };
});
const baseWidth = useMemo(() => {
if (uiWidth != null) return uiWidth;
if (fallbackViewSize != null) return fallbackViewSize.width;
return 0;
}, [uiWidth, fallbackViewSize]);
const baseHeight = useMemo(() => {
if (uiHeight != null) return uiHeight;
if (fallbackViewSize != null) return fallbackViewSize.height;
return 0;
}, [uiHeight, fallbackViewSize]);
const focusIframe = useCallback(() => {
iframeRef.current?.focus();
try {
iframeRef.current?.contentWindow?.focus();
} catch {}
}, []);
const focusIframeSoon = useCallback(() => {
setTimeout(() => {
focusIframe();
}, 0);
}, [focusIframe]);
const recomputeScale = useCallback(async () => {
if (!hasExplicitWindowSize) {
setScale(1);
return;
}
const size = await platformAdapter.getWindowSize();
const nextScale = Math.min(size.width / baseWidth, size.height / baseHeight);
setScale(Math.max(nextScale, 0.1));
}, [baseHeight, baseWidth, hasExplicitWindowSize]);
const applyFullscreen = useCallback(
async (next: boolean, options?: { centerOnExit?: boolean }) => {
const centerOnExit = options?.centerOnExit ?? true;
if (next) {
const size = await platformAdapter.getWindowSize();
const resizable = await platformAdapter.isWindowResizable();
const pos = await platformAdapter.getWindowPosition();
fullscreenPrevRef.current = {
width: size.width,
height: size.height,
resizable,
x: pos.x,
y: pos.y,
};
if (isMac && isTauri) {
const monitor = await platformAdapter.getMonitorFromCursor();
if (!monitor) return;
const window = await platformAdapter.getCurrentWebviewWindow();
const factor = await window.scaleFactor();
const { size, position } = monitor;
const { width, height } = size.toLogical(factor);
const { x, y } = position.toLogical(factor);
await platformAdapter.setWindowSize(width, height);
await platformAdapter.setWindowPosition(x, y);
await platformAdapter.setWindowResizable(true);
await recomputeScale();
} else {
await platformAdapter.setWindowFullscreen(true);
await recomputeScale();
}
} else {
const prevPos =
fullscreenPrevRef.current != null
? { x: fullscreenPrevRef.current.x, y: fullscreenPrevRef.current.y }
: null;
if (!isMac) {
await platformAdapter.setWindowFullscreen(false);
}
if (fullscreenPrevRef.current) {
const prev = fullscreenPrevRef.current;
await platformAdapter.setWindowSize(prev.width, prev.height);
await platformAdapter.setWindowResizable(prev.resizable);
fullscreenPrevRef.current = null;
} else if (hasExplicitWindowSize) {
const nextResizable =
ui && typeof ui.resizable === "boolean" ? ui.resizable : true;
await platformAdapter.setWindowSize(uiWidth, uiHeight);
await platformAdapter.setWindowResizable(nextResizable);
}
if (centerOnExit) {
await platformAdapter.centerOnCurrentMonitor();
} else if (prevPos != null) {
await platformAdapter.setWindowPosition(prevPos.x, prevPos.y);
}
await recomputeScale();
focusIframeSoon();
}
},
[
focusIframeSoon,
hasExplicitWindowSize,
isTauri,
recomputeScale,
ui,
uiHeight,
uiWidth,
]
);
const toggleFullscreen = useCallback(async () => {
const next = !isFullscreen;
await applyFullscreen(next);
setIsFullscreen(next);
if (next) focusIframe();
}, [applyFullscreen, focusIframe, isFullscreen]);
useEffect(() => {
const applyWindowSettings = async () => {
const size = await platformAdapter.getWindowSize();
const resizable = await platformAdapter.isWindowResizable();
const pos = await platformAdapter.getWindowPosition();
setFallbackViewSize({ width: size.width, height: size.height });
prevWindowRef.current = {
width: size.width,
height: size.height,
resizable,
x: pos.x,
y: pos.y,
};
if (hasExplicitWindowSize) {
const nextResizable =
ui && typeof ui.resizable === "boolean" ? ui.resizable : true;
await platformAdapter.setWindowSize(uiWidth, uiHeight);
await platformAdapter.setWindowResizable(nextResizable);
await platformAdapter.centerOnCurrentMonitor();
await recomputeScale();
} else {
await recomputeScale();
}
focusIframeSoon();
};
applyWindowSettings();
return () => {
if (prevWindowRef.current) {
const prev = prevWindowRef.current;
if (!isMac && fullscreenPrevRef.current != null) {
platformAdapter.setWindowFullscreen(false);
}
platformAdapter.setWindowSize(prev.width, prev.height);
platformAdapter.setWindowResizable(prev.resizable);
platformAdapter.setWindowPosition(prev.x, prev.y);
prevWindowRef.current = null;
fullscreenPrevRef.current = null;
}
};
}, [
focusIframeSoon,
hasExplicitWindowSize,
recomputeScale,
ui,
uiHeight,
uiWidth,
]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isFullscreen) {
applyFullscreen(false);
setIsFullscreen(false);
}
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
return () => {
window.removeEventListener("keydown", handleKeyDown, {
capture: true,
} as any);
};
}, [applyFullscreen, isFullscreen]);
return {
ui,
resizable,
scale,
iframeRef,
isFullscreen,
toggleFullscreen,
focusIframe,
};
}

View File

@@ -442,6 +442,19 @@
"placeholder": "Ask More",
"continueInChat": "Continue in chat",
"copy": "Copy"
},
"fuzziness": {
"fuzzyMatch": "Fuzzy Match"
},
"filters": {
"allTime": "All Time",
"past7Days": "Past 7 Days",
"past90Days": "Past 90 Days",
"past1year": "Past 1 Year",
"more": "More",
"updateTime": "Update Time",
"selectDateRange": "Select Date Range",
"filters": "Filters"
}
},
"assistant": {

View File

@@ -442,6 +442,19 @@
"placeholder": "问更多",
"continueInChat": "继续聊天",
"copy": "复制"
},
"fuzziness": {
"fuzzyMatch": "模糊匹配"
},
"filters": {
"allTime": "全部时间",
"past7Days": "过去 7 天",
"past90Days": "过去 90 天",
"past1year": "过去 1 年",
"more": "更多",
"updateTime": "更新时间",
"selectDateRange": "选择日期范围",
"filters": "筛选"
}
},
"assistant": {

View File

@@ -1,114 +0,0 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import { Slider } from "@/components/ui/slider";
import { MultiSelect } from "@/components/ui/multi-select";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
const ShadcnDemo = () => {
const [checked, setChecked] = useState(false);
const [enabled, setEnabled] = useState(false);
const [sliderValue, setSliderValue] = useState<number[]>([50]);
const [selected, setSelected] = useState<string[]>([]);
const options = [
{ value: "a", label: "选项 A" },
{ value: "b", label: "选项 B" },
{ value: "c", label: "选项 C" },
{ value: "d", label: "选项 D" },
];
return (
<div className="p-6 space-y-6">
<h2 className="text-xl font-semibold">Shadcn </h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input id="name" placeholder="输入名称" />
</div>
<div className="space-y-2">
<Label></Label>
<Select>
<SelectTrigger>
<SelectValue placeholder="请选择" />
</SelectTrigger>
<SelectContent>
<SelectItem value="a"> A</SelectItem>
<SelectItem value="b"> B</SelectItem>
<SelectItem value="c"> C</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Separator />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<MultiSelect options={options} value={selected} onChange={setSelected} />
<div className="text-sm text-muted-foreground">{selected.length ? selected.join(", ") : "无"}</div>
</div>
<div className="space-y-2">
<Label>{sliderValue[0]}</Label>
<Slider value={sliderValue} onValueChange={setSliderValue} max={100} step={1} />
</div>
</div>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<Checkbox checked={checked} onCheckedChange={(v) => setChecked(Boolean(v))} />
<span>{checked ? "已选" : "未选"}</span>
</div>
<div className="flex items-center gap-2">
<Switch checked={enabled} onCheckedChange={setEnabled} />
<span>{enabled ? "开启" : "关闭"}</span>
</div>
</div>
<div className="flex gap-3">
<Button></Button>
<Button variant="secondary"></Button>
<Button variant="outline"></Button>
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost"></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription> Shadcn Dialog </DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="secondary"></Button>
<Button></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
);
};
export default ShadcnDemo;

View File

@@ -41,7 +41,7 @@ export type IAppStore = {
blurred: boolean;
setBlurred: (blurred: boolean) => void;
suppressErrors: boolean;
setSuppressErrors: (suppressErrors: boolean) => void;
};
@@ -130,4 +130,4 @@ export const useAppStore = create<IAppStore>()(
}
)
)
);
);

View File

@@ -3,6 +3,8 @@ import {
ExtensionPermission,
ViewExtensionUISettings,
} from "@/components/Settings/Extensions";
import { AggregationBucket, Aggregations } from "@/types/search";
import { DateRange } from "react-day-picker";
import { create } from "zustand";
import { persist } from "zustand/middleware";
@@ -14,9 +16,13 @@ export type ViewExtensionOpened = [
// HTML file URL
string,
ExtensionPermission | null,
ViewExtensionUISettings | null,
ViewExtensionUISettings | null
];
export interface AggregateFilter {
[key: string]: AggregationBucket[];
}
export type ISearchStore = {
sourceData: any;
setSourceData: (sourceData: any) => void;
@@ -64,8 +70,28 @@ export type ISearchStore = {
// When we open a View extension, we set this to a non-null value.
viewExtensionOpened?: ViewExtensionOpened;
setViewExtensionOpened: (showViewExtension?: ViewExtensionOpened) => void;
enabledFuzzyMatch: boolean;
setEnabledFuzzyMatch: (enabledFuzzyMatch: boolean) => void;
fuzziness: number;
setFuzziness: (fuzziness: number) => void;
filterDateRange?: DateRange;
setFilterDateRange: (filterDateRange?: DateRange) => void;
aggregateFilter?: AggregateFilter;
setAggregateFilter: (aggregateFilter?: AggregateFilter) => void;
aggregations?: Aggregations;
setAggregations: (aggregations?: Aggregations) => void;
filterMultiSelectOpened: boolean;
setFilterMultiSelectOpened: (filterMultiSelectOpened: boolean) => void;
};
export const DEFAULT_FUZZINESS = 3;
export const useSearchStore = create<ISearchStore>()(
persist(
(set) => ({
@@ -138,11 +164,33 @@ export const useSearchStore = create<ISearchStore>()(
setViewExtensionOpened: (viewExtensionOpened) => {
return set({ viewExtensionOpened });
},
enabledFuzzyMatch: false,
setEnabledFuzzyMatch: (enabledFuzzyMatch) => {
return set({ enabledFuzzyMatch });
},
fuzziness: DEFAULT_FUZZINESS,
setFuzziness: (fuzziness) => {
return set({ fuzziness });
},
setFilterDateRange(filterDateRange) {
return set({ filterDateRange });
},
setAggregateFilter: (aggregateFilter) => {
return set({ aggregateFilter });
},
setAggregations: (aggregations) => {
return set({ aggregations });
},
filterMultiSelectOpened: false,
setFilterMultiSelectOpened: (filterMultiSelectOpened) => {
return set({ filterMultiSelectOpened });
},
}),
{
name: "search-store",
partialize: (state) => ({
sourceData: state.sourceData,
fuzziness: state.fuzziness,
}),
}
)

View File

@@ -71,8 +71,23 @@ export interface FailedRequest {
reason?: string;
}
export interface AggregationBucket {
key: string;
label: string;
doc_count: number;
}
export interface Aggregation {
buckets: AggregationBucket[];
}
export interface Aggregations {
[key: string]: Aggregation;
}
export interface MultiSourceQueryResponse {
failed: FailedRequest[];
hits: QueryHits[];
total_hits: number;
aggregations?: Aggregations;
}

19
src/utils/date.ts Normal file
View File

@@ -0,0 +1,19 @@
import dayjs from "dayjs";
import type { ConfigType } from "dayjs";
// Format "date" to local time. Fall back to "-" if it is invalid.
export const formatDateToLocal = (date?: ConfigType) => {
const fallback = "-";
// Fall back if it is null/undefined/emptystr
if (date === null || date === undefined || date === "") return fallback;
const d = dayjs(date);
// Fall back if it is invalid
if (!d.isValid()) {
return fallback;
}
return d.format("YYYY/MM/DD HH:mm:ss");
};

View File

@@ -1,5 +1,13 @@
import { useEffect, useState } from "react";
import { isArray, isNil, isObject, isString } from "lodash-es";
import {
fromPairs,
isArray,
isNil,
isObject,
isString,
sortBy,
toPairs,
} from "lodash-es";
import { filesize as filesizeLib } from "filesize";
import i18next from "i18next";
@@ -9,6 +17,8 @@ import { DEFAULT_COCO_SERVER_ID, HISTORY_PANEL_ID } from "@/constants";
import { useChatStore } from "@/stores/chatStore";
import { getCurrentWindowService } from "@/commands/windowService";
import { useSearchStore } from "@/stores/searchStore";
import { MultiSourceQueryResponse } from "@/types/search";
import dayjs from "dayjs";
export async function copyToClipboard(text: string, noTip = false) {
const addError = useAppStore.getState().addError;
@@ -303,20 +313,24 @@ export const visibleSearchBar = () => {
const ui = viewExtensionOpened[4];
return ui?.search_bar ?? true;
return ui?.search_bar ?? false;
};
export const visibleFilterBar = () => {
const { viewExtensionOpened, visibleExtensionDetail, goAskAi } =
useSearchStore.getState();
const {
viewExtensionOpened,
visibleExtensionStore,
visibleExtensionDetail,
goAskAi,
} = useSearchStore.getState();
if (visibleExtensionDetail || goAskAi) return false;
if (visibleExtensionStore || visibleExtensionDetail || goAskAi) return false;
if (isNil(viewExtensionOpened)) return true;
const ui = viewExtensionOpened[4];
return ui?.filter_bar ?? true;
return ui?.filter_bar ?? false;
};
export const visibleFooterBar = () => {
@@ -326,7 +340,7 @@ export const visibleFooterBar = () => {
const ui = viewExtensionOpened[4];
return ui?.footer ?? true;
return ui?.footer ?? false;
};
export const installExtensionError = (error: any) => {
@@ -404,3 +418,64 @@ export const installExtensionError = (error: any) => {
addError(i18next.t(message));
};
export const getQueryStrings = (queryStrings: Record<string, string>) => {
const { fuzziness, aggregateFilter, filterDateRange } =
useSearchStore.getState();
const nextQueryStrings: Record<string, string> = {
...queryStrings,
fuzziness: String(fuzziness),
};
if (filterDateRange) {
const { from, to } = filterDateRange;
if (from) {
nextQueryStrings["update_time_start"] = dayjs(from).startOf('day').format('YYYY-MM-DD[T]HH:mm:ss.SSSZ');
}
if (to) {
nextQueryStrings["update_time_end"] = dayjs(to).endOf('day').format('YYYY-MM-DD[T]HH:mm:ss.SSSZ');
}
}
if (aggregateFilter) {
for (const [key, value] of Object.entries(aggregateFilter)) {
if (value.length === 0) continue;
const result = value.map((item) => item.key).join(",");
queryStrings[key] = `any(${result})`;
}
}
return nextQueryStrings;
};
export const updateAggregations = (result?: MultiSourceQueryResponse) => {
const { isTauri } = useAppStore.getState();
if (!isTauri) return;
const { setAggregations, setAggregateFilter } = useSearchStore.getState();
if (result?.aggregations) {
const sortedAggregations = fromPairs(
sortBy(toPairs(result.aggregations), ([key]) => key)
);
for (const [key, value] of Object.entries(sortedAggregations)) {
sortedAggregations[key].buckets = value.buckets.map((item) => ({
...item,
label: item.label ?? item.key,
}));
}
setAggregations(sortedAggregations);
} else {
setAggregations(void 0);
setAggregateFilter(void 0);
}
};

View File

@@ -46,7 +46,6 @@ export interface TauriPlatformAdapter extends BasePlatformAdapter {
setWindowMaximized: (enable: boolean) => Promise<void>;
getWindowPosition: () => Promise<{ x: number; y: number }>;
setWindowPosition: (x: number, y: number) => Promise<void>;
centerWindow: () => Promise<void>;
getMonitorFromCursor: () => Promise<Monitor | null>;
centerOnCurrentMonitor: () => Promise<unknown>;
}
@@ -81,9 +80,6 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
async setWindowPosition(x, y) {
return windowWrapper.setLogicalPosition(x, y);
},
async centerWindow() {
return windowWrapper.center();
},
async getMonitorFromCursor() {
const appWindow = getCurrentWebviewWindow();

View File

@@ -17,9 +17,9 @@ export interface WebPlatformAdapter extends BasePlatformAdapter {
getWindowSize: () => Promise<{ width: number; height: number }>;
setWindowFullscreen: (enable: boolean) => Promise<void>;
getMonitorFromCursor: () => Promise<any>;
centerOnCurrentMonitor: () => Promise<void>;
getWindowPosition: () => Promise<{ x: number; y: number }>;
setWindowPosition: (x: number, y: number) => Promise<void>;
centerOnCurrentMonitor: () => Promise<void>;
}
// Create Web adapter functions
@@ -71,6 +71,7 @@ export const createWebAdapter = (): WebPlatformAdapter => {
},
};
},
async centerOnCurrentMonitor() {
// Not applicable in web mode
return;

View File

@@ -1,5 +1,4 @@
import * as commands from "@/commands";
import { WINDOW_CENTER_BASELINE_HEIGHT } from "@/constants";
import platformAdapter from "../platformAdapter";
// Window operations
@@ -16,9 +15,6 @@ export const windowWrapper = {
const window = await this.getCurrentWebviewWindow();
if (window) {
await window.setSize(new LogicalSize(width, height));
if (height < WINDOW_CENTER_BASELINE_HEIGHT) {
await window.center();
}
}
},
async getLogicalSize() {
@@ -51,12 +47,6 @@ export const windowWrapper = {
const win = getCurrentWindow();
return win.setFullscreen(enable);
},
async center() {
const window = await this.getCurrentWebviewWindow();
if (window) {
return window.center();
}
},
async setLogicalPosition(x: number, y: number) {
const { LogicalPosition } = await import("@tauri-apps/api/dpi");