63 Commits

Author SHA1 Message Date
Steve Lau
4d305f832a update 2025-12-23 16:47:04 +08:00
ayang
be54c884a0 refactor: update 2025-12-23 15:51:04 +08:00
ayang
4e6ccd27b0 refactor: update 2025-12-23 15:43:59 +08:00
ayang
7b0c9614aa refactor: update 2025-12-23 15:37:57 +08:00
ayang
9a6a4664fe refactor: update 2025-12-23 15:20:44 +08:00
ayang
1fb6c658f3 refactor: update 2025-12-23 15:03:00 +08:00
ayang
70bd96a2df refactor: update 2025-12-23 10:35:57 +08:00
ayang
8735eb4754 refactor: update 2025-12-23 10:17:41 +08:00
ayang
f236049b4a refactor: update 2025-12-23 10:17:15 +08:00
Steve Lau
a808b984b3 typo filer->filter 2025-12-23 09:45:22 +08:00
Steve Lau
de4b009b51 fix import 2025-12-23 09:39:42 +08:00
Steve Lau
b84e45daaa move clean_aggregations() to common/search.rs 2025-12-23 09:38:13 +08:00
Steve Lau
a15f92b33c remove debugging printlns 2025-12-23 09:27:18 +08:00
ayang
af6a2c76e2 refactor: update 2025-12-22 18:16:57 +08:00
ayang
33c68d9f9f refactor: update 2025-12-22 16:06:53 +08:00
ayang
71ce892a90 refactor: update 2025-12-22 15:58:34 +08:00
ayang
1e7247b0dd refactor: update 2025-12-22 14:27:58 +08:00
Steve Lau
730a3b96d7 docs: document supported query strings 2025-12-22 10:55:45 +08:00
ayang
6ab93407ed refactor: update 2025-12-19 18:21:58 +08:00
ayang
a488b0b465 refactor: update 2025-12-19 18:21:35 +08:00
ayang
5fdbc7cd31 refactor: update 2025-12-19 18:14:22 +08:00
ayang
6d00efc7e8 refactor: update 2025-12-19 17:49:18 +08:00
ayang
798bd923c6 refactor: update 2025-12-19 17:48:52 +08:00
ayang
56234526a8 refactor: update 2025-12-19 17:45:11 +08:00
Steve Lau
5fe4032209 fix: when field buckets does not exist 2025-12-19 17:39:43 +08:00
ayang
8d0d719964 refactor: update 2025-12-19 17:13:08 +08:00
Steve Lau
d056271848 fix: source ID filter 2025-12-19 17:10:47 +08:00
ayang
b11fec29dc refactor: update 2025-12-19 17:10:04 +08:00
ayang
97369963a6 refactor: update 2025-12-19 17:09:03 +08:00
Steve Lau
f3fa91a03c fix: source ID filter 2025-12-19 17:07:55 +08:00
ayang
0e6d7fa52f refactor: update 2025-12-19 17:02:14 +08:00
Steve Lau
209438a638 filter 2025-12-19 16:53:23 +08:00
ayang
4ada34ad75 refactor: update 2025-12-19 16:52:12 +08:00
ayang
52f6f73d53 refactor: update 2025-12-19 16:47:56 +08:00
ayang
aa779ec156 refactor: update 2025-12-19 16:46:00 +08:00
ayang
93da46662c refactor: update 2025-12-19 16:30:43 +08:00
Steve Lau
fbbc5f1d6a Bucket.label 2025-12-19 16:26:57 +08:00
ayang
e89cca1c2f refactor: update 2025-12-19 15:43:03 +08:00
ayang
5e103bfc3d refactor: update 2025-12-19 15:37:32 +08:00
ayang
78ec0836a1 refactor: update 2025-12-19 15:31:32 +08:00
ayang
c698b4094b refactor: update 2025-12-19 14:44:00 +08:00
ayang
2235bd1da1 refactor: update 2025-12-19 12:10:41 +08:00
ayang
8a2898b0b9 refactor: update 2025-12-19 12:08:50 +08:00
ayang
53f6b33279 refactor: update 2025-12-19 12:06:35 +08:00
rain9
b20cc771ea Merge branch 'add-search-filter' of github.com:infinilabs/coco-app into add-search-filter 2025-12-19 12:00:14 +08:00
rain9
a339dbab9c chore: up 2025-12-19 11:59:58 +08:00
Steve Lau
1bb2d8b3b3 pass request body 2025-12-19 11:50:36 +08:00
Steve Lau
1ceb385505 print response body string 2025-12-19 11:40:42 +08:00
Steve Lau
371f8d0daa pass v2=true to coco server 2025-12-19 11:38:10 +08:00
Steve Lau
28cf5ca326 update 2025-12-19 11:35:04 +08:00
Steve Lau
ed10be5c6f update 2025-12-19 11:33:29 +08:00
ayang
0bcb974837 style: remove unless file 2025-12-19 11:30:42 +08:00
Steve Lau
4c4c08a598 update 2025-12-19 11:27:30 +08:00
Steve Lau
724db0f66d update 2025-12-19 11:24:51 +08:00
Steve Lau
a8a14cae18 update 2025-12-19 11:24:51 +08:00
ayang
eea6a7a5ae refactor: update 2025-12-19 11:23:56 +08:00
ayang
fdc8967b76 refactor: update 2025-12-19 11:22:22 +08:00
ayang
5df1f9668d refactor: update 2025-12-18 18:18:11 +08:00
ayang
23607e6b4c refactor: update 2025-12-18 16:55:47 +08:00
ayang
494be3db62 refactor: update 2025-12-18 16:51:49 +08:00
Steve Lau
530502ecff debugging: print query strings 2025-12-18 16:00:31 +08:00
ayang
8d6204a9d8 refactor: update 2025-12-18 15:59:37 +08:00
ayang
ca350dfeed feat: add search filter 2025-12-18 15:54:59 +08:00
65 changed files with 1604 additions and 1324 deletions

2
.gitignore vendored
View File

@@ -29,4 +29,4 @@ web.md
*.sw?
.env
.trae
.trae

View File

@@ -59,7 +59,6 @@
"serde",
"Shadcn",
"swatinem",
"systempreferences",
"tailwindcss",
"tauri",
"thiserror",
@@ -85,12 +84,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

@@ -13,7 +13,6 @@ Information about release notes of Coco App is provided here.
### 🚀 Features
- feat: resizable extension UI #1009
- feat: add open button to launch installed extension #1013
### 🐛 Bug fix

0
foo
View File

View File

@@ -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

12
src-tauri/Cargo.lock generated
View File

@@ -1184,7 +1184,6 @@ dependencies = [
"scraper",
"semver",
"serde",
"serde-inline-default",
"serde_json",
"serde_plain",
"snafu",
@@ -6309,17 +6308,6 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-inline-default"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d48532bc0781ac622a5fea0f16502d3b4f1af0fcebe56d618120969f35d315"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "serde-untagged"
version = "0.1.9"

View File

@@ -122,7 +122,6 @@ actix-web = "4.11.0"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-zustand = "1"
snafu = "0.8.9"
serde-inline-default = "1.0.0"
[dev-dependencies]
tempfile = "3.23.0"

View File

@@ -31,11 +31,6 @@
"core:window:deny-internal-toggle-maximize",
"core:window:allow-set-shadow",
"core:window:allow-set-position",
"core:window:allow-set-theme",
"core:window:allow-unminimize",
"core:window:allow-set-fullscreen",
"core:window:allow-set-resizable",
"core:window:allow-maximize",
"core:app:allow-set-app-theme",
"shell:default",
"http:default",
@@ -70,10 +65,12 @@
"fs-pro:default",
"macos-permissions:default",
"screenshots:default",
"core:window:allow-set-theme",
"process:default",
"updater:default",
"windows-version:default",
"log:default",
"opener:default"
"opener:default",
"core:window:allow-unminimize"
]
}

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

@@ -152,31 +152,14 @@ pub struct Extension {
}
/// Settings that control the built-in UI Components
#[serde_inline_default::serde_inline_default]
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub(crate) struct ViewExtensionUISettings {
/// Show the search bar
#[serde_inline_default(true)]
search_bar: bool,
/// Show the filter bar
#[serde_inline_default(true)]
filter_bar: bool,
/// Show the footer
#[serde_inline_default(true)]
footer: bool,
/// The recommended width of the window for this extension
width: Option<u32>,
/// The recommended heigh of the window for this extension
height: Option<u32>,
/// Is the extension window's size adjustable?
#[serde_inline_default(false)]
resizable: bool,
/// Detch the extension window from Coco's main window.
///
/// If true, user can click the detach button to open this
/// extension in a seprate window.
#[serde_inline_default(false)]
detachable: bool,
}
/// Bundle ID uniquely identifies an extension.

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

@@ -2,14 +2,12 @@ mod assistant;
mod autostart;
mod common;
mod extension;
mod macos;
mod search;
mod selection_monitor;
mod server;
mod settings;
mod setup;
mod shortcut;
// We need this in main.rs, so it has to be pub
pub mod util;
@@ -208,10 +206,6 @@ pub fn run() {
util::logging::app_log_dir,
selection_monitor::set_selection_enabled,
selection_monitor::get_selection_enabled,
macos::permissions::check_accessibility_trusted,
macos::permissions::open_accessibility_settings,
macos::permissions::open_screen_recording_settings,
macos::permissions::open_microphone_settings,
])
.setup(|app| {
#[cfg(target_os = "macos")]

View File

@@ -1 +0,0 @@
pub mod permissions;

View File

@@ -1,58 +0,0 @@
#[tauri::command]
pub fn check_accessibility_trusted() -> bool {
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
let trusted = macos_accessibility_client::accessibility::application_is_trusted();
log::info!(target: "coco_lib::permissions", "check_accessibility_trusted invoked: {}", trusted);
trusted
} else {
log::info!(target: "coco_lib::permissions", "check_accessibility_trusted invoked on non-macOS: false");
false
}
}
}
#[tauri::command]
pub fn open_accessibility_settings() {
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
use std::process::Command;
log::info!(target: "coco_lib::permissions", "open_accessibility_settings invoked");
let _ = Command::new("open")
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
.status();
} else {
// no-op on non-macOS
}
}
}
#[tauri::command]
pub fn open_screen_recording_settings() {
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
use std::process::Command;
log::info!(target: "coco_lib::permissions", "open_screen_recording_settings invoked");
let _ = Command::new("open")
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording")
.status();
} else {
// no-op on non-macOS
}
}
}
#[tauri::command]
pub fn open_microphone_settings() {
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
use std::process::Command;
log::info!(target: "coco_lib::permissions", "open_microphone_settings invoked");
let _ = Command::new("open")
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")
.status();
} else {
// no-op on non-macOS
}
}
}

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

@@ -2,7 +2,6 @@
/// Coordinates use logical (Quartz) points with a top-left origin.
/// Note: `y` is flipped on the backend to match the frontends usage.
use tauri::Emitter;
use tauri::Manager;
#[derive(serde::Serialize, Clone)]
struct SelectionEventPayload {
@@ -15,8 +14,8 @@ use once_cell::sync::Lazy;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
/// Global toggle: selection monitoring enabled for this release.
static SELECTION_ENABLED: AtomicBool = AtomicBool::new(true);
/// Global toggle: selection monitoring disabled for this release.
static SELECTION_ENABLED: AtomicBool = AtomicBool::new(false);
/// Ensure we only start the monitor thread once. Allows delayed start after
/// Accessibility permission is granted post-launch.
@@ -25,9 +24,6 @@ static MONITOR_THREAD_STARTED: AtomicBool = AtomicBool::new(false);
/// Guard to avoid spawning multiple permission watcher threads.
#[cfg(target_os = "macos")]
static PERMISSION_WATCHER_STARTED: AtomicBool = AtomicBool::new(false);
/// Guard to avoid spawning multiple selection store watcher threads.
#[cfg(target_os = "macos")]
static SELECTION_STORE_WATCHER_STARTED: AtomicBool = AtomicBool::new(false);
/// Session flags for controlling macOS Accessibility prompts.
#[cfg(target_os = "macos")]
@@ -35,8 +31,6 @@ static SEEN_ACCESSIBILITY_TRUSTED_ONCE: AtomicBool = AtomicBool::new(false);
#[cfg(target_os = "macos")]
static LAST_ACCESSIBILITY_PROMPT: Lazy<Mutex<Option<std::time::Instant>>> =
Lazy::new(|| Mutex::new(None));
#[cfg(target_os = "macos")]
static LAST_READ_WARN: Lazy<Mutex<Option<std::time::Instant>>> = Lazy::new(|| Mutex::new(None));
#[derive(serde::Serialize, Clone)]
struct SelectionEnabledPayload {
@@ -101,19 +95,7 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
use tauri::Emitter;
// Sync initial enabled state to the frontend on startup.
// Prefer disk-persisted Zustand store if present
#[cfg(target_os = "macos")]
ensure_selection_store_bootstrap(&app_handle);
if let Some(enabled) = read_selection_enabled_from_store(&app_handle) {
log::info!(target: "coco_lib::selection_monitor", "initial selection-enabled loaded from store: {}", enabled);
set_selection_enabled_internal(&app_handle, enabled);
} else {
log::warn!(target: "coco_lib::selection_monitor", "initial selection-enabled not found in store, falling back to in-memory flag");
set_selection_enabled_internal(&app_handle, is_selection_enabled());
}
// Start a light watcher to keep SELECTION_ENABLED in sync with disk
start_selection_store_watcher(app_handle.clone());
log::info!(target: "coco_lib::selection_monitor", "selection store watcher started");
set_selection_enabled_internal(&app_handle, is_selection_enabled());
// Accessibility permission is required to read selected text in the foreground app.
// If not granted, prompt the user once; if still not granted, skip starting the watcher.
@@ -305,7 +287,6 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
// If disabled: do not read AX / do not show popup; hide if currently visible.
if !is_selection_enabled() {
log::debug!(target: "coco_lib::selection_monitor", "monitor loop: selection disabled");
if popup_visible {
let _ = app_handle.emit("selection-detected", "");
popup_visible = false;
@@ -463,118 +444,6 @@ fn ensure_accessibility_permission(app_handle: &tauri::AppHandle) -> bool {
false
}
/// Resolve the path to the zustand store file `selection-store.json`.
#[cfg(target_os = "macos")]
fn selection_store_path(app_handle: &tauri::AppHandle) -> std::path::PathBuf {
let mut dir = app_handle
.path()
.app_data_dir()
.expect("failed to find the local dir");
dir.push("zustand");
dir.push("selection-store.json");
log::debug!(target: "coco_lib::selection_monitor", "selection_store_path resolved: {}", dir.display());
dir
}
#[cfg(target_os = "macos")]
fn ensure_selection_store_bootstrap(app_handle: &tauri::AppHandle) {
use std::fs;
use std::io::Write;
let mut dir = app_handle
.path()
.app_data_dir()
.expect("failed to find the local dir");
dir.push("zustand");
let _ = fs::create_dir_all(&dir);
let file = dir.join("selection-store.json");
if !file.exists() {
let initial = serde_json::json!({
"selectionEnabled": true,
"iconsOnly": false,
"toolbarConfig": []
});
if let Ok(mut f) = fs::File::create(&file) {
let _ = f.write_all(
serde_json::to_string(&initial)
.unwrap_or_else(|_| "{}".to_string())
.as_bytes(),
);
log::info!(target: "coco_lib::selection_monitor", "bootstrap selection-store.json created: {}", file.display());
}
}
}
/// Read `selectionEnabled` from the persisted zustand store.
/// Returns Some(bool) if read succeeds; None otherwise.
#[cfg(target_os = "macos")]
fn read_selection_enabled_from_store(app_handle: &tauri::AppHandle) -> Option<bool> {
use std::fs;
let path = selection_store_path(app_handle);
match fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
Ok(v) => {
let val = v.get("selectionEnabled").and_then(|b| b.as_bool());
log::info!(target: "coco_lib::selection_monitor", "read_selection_enabled_from_store: {} -> {:?}", path.display(), val);
val
}
Err(e) => {
log::warn!(target: "coco_lib::selection_monitor", "read_selection_enabled_from_store: JSON parse failed for {}: {}", path.display(), e);
None
}
},
Err(e) => {
use std::time::Duration;
use std::time::Instant;
let mut last = LAST_READ_WARN.lock().unwrap();
let now = Instant::now();
let allow = match *last {
Some(ts) => now.duration_since(ts) > Duration::from_secs(30),
None => true,
};
if allow {
log::warn!(target: "coco_lib::selection_monitor", "read_selection_enabled_from_store: read failed for {}: {}", path.display(), e);
*last = Some(now);
} else {
log::debug!(target: "coco_lib::selection_monitor", "read_selection_enabled_from_store: read failed suppressed for {}", path.display());
}
None
}
}
}
/// Spawn a background watcher to sync `SELECTION_ENABLED` with disk every ~1s.
#[cfg(target_os = "macos")]
fn start_selection_store_watcher(app_handle: tauri::AppHandle) {
if SELECTION_STORE_WATCHER_STARTED.swap(true, Ordering::Relaxed) {
return;
}
std::thread::Builder::new()
.name("selection-store-watcher".into())
.spawn(move || {
use std::time::{Duration, Instant};
let mut last_check = Instant::now();
let mut last_val: Option<bool> = None;
loop {
// Check approximately every second
if last_check.elapsed() >= Duration::from_secs(1) {
let current = read_selection_enabled_from_store(&app_handle);
if current.is_some() && current != last_val {
let enabled = current.unwrap();
set_selection_enabled_internal(&app_handle, enabled);
log::info!(target: "coco_lib::selection_monitor", "selection-store-watcher: detected change, enabled={}", enabled);
last_val = current;
}
last_check = Instant::now();
}
std::thread::sleep(Duration::from_millis(200));
}
})
.unwrap_or_else(|e| {
SELECTION_STORE_WATCHER_STARTED.store(false, Ordering::Relaxed);
panic!("selection-store-watcher: failed to spawn: {}", e);
});
}
#[cfg(target_os = "macos")]
fn collect_selection_permission_info() -> SelectionPermissionInfo {
let exe_path = std::env::current_exe()

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

@@ -32,7 +32,7 @@ pub fn platform(
let panel = main_window.to_panel::<NsPanel>().unwrap();
// set level
panel.set_level(PanelLevel::Dock.value());
panel.set_level(PanelLevel::Utility.value());
// Do not steal focus from other windows
panel.set_style_mask(StyleMask::empty().nonactivating_panel().into());

View File

@@ -110,7 +110,6 @@ pub(crate) async fn backend_setup(tauri_app_handle: AppHandle, app_lang: String)
// Start system-wide selection monitor (macOS-only currently)
#[cfg(target_os = "macos")]
{
log::info!("backend_setup: starting system-wide selection monitor");
crate::selection_monitor::start_selection_monitor(tauri_app_handle.clone());
}

View File

@@ -20,7 +20,7 @@
"width": 680,
"decorations": false,
"minimizable": false,
"maximizable": true,
"maximizable": false,
"skipTaskbar": true,
"resizable": false,
"acceptFirstMouse": true,

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

@@ -57,13 +57,13 @@ const ErrorNotification = ({
>
<div className="flex items-center">
{visibleError.type === "error" && (
<AlertCircle className="size-5 shrink-0 text-red-500 mr-2" />
<AlertCircle className="w-5 h-5 text-red-500 mr-2" />
)}
{visibleError.type === "warning" && (
<AlertTriangle className="size-5 shrink-0 text-yellow-500 mr-2" />
<AlertTriangle className="w-5 h-5 text-yellow-500 mr-2" />
)}
{visibleError.type === "info" && (
<Info className="size-5 shrink-0 text-blue-500 mr-2" />
<Info className="w-5 h-5 text-blue-500 mr-2" />
)}
<span className="text-sm text-gray-700 dark:text-gray-200">
@@ -78,7 +78,7 @@ const ErrorNotification = ({
</div>
<X
className="size-5 shrink-0 ml-4 cursor-pointer text-gray-400 hover:text-gray-600"
className="w-5 h-5 ml-4 cursor-pointer text-gray-400 hover:text-gray-600"
onClick={() => removeError(visibleError.id)}
/>
</div>

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

@@ -1,6 +1,5 @@
import { useCallback, useRef, useMemo, useState, useEffect } from "react";
import { cloneDeep, isEmpty } from "lodash-es";
import { useKeyPress } from "ahooks";
import { useSearchStore } from "@/stores/searchStore";
import { useExtensionsStore } from "@/stores/extensionsStore";
@@ -9,6 +8,7 @@ import { Get } from "@/api/axiosRequest";
import type { Assistant } from "@/types/chat";
import { useAppStore } from "@/stores/appStore";
import { canNavigateBack, navigateBack } from "@/utils";
import { useKeyPress } from "ahooks";
import { useShortcutsStore } from "@/stores/shortcutsStore";
interface AssistantManagerProps {
@@ -167,7 +167,7 @@ export function useAssistantManager({
const { selectedSearchContent, visibleExtensionStore } =
useSearchStore.getState();
// console.log("selectedSearchContent", selectedSearchContent);
console.log("selectedSearchContent", selectedSearchContent);
const { id, type, category } = selectedSearchContent ?? {};

View File

@@ -20,7 +20,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 +66,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 +119,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>
}

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

@@ -118,7 +118,7 @@ const ExtensionDetail: FC<ExtensionDetailProps> = (props) => {
</div>
<div className="flex items-center gap-1">
<FolderDown className="size-4" />
<span>{selectedExtension.stats?.installs ?? 0}</span>
<span>{selectedExtension.stats.installs}</span>
</div>
</div>
</div>

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>
@@ -348,7 +348,7 @@ const ExtensionStore = ({
<div className="flex items-center gap-1 text-[#999]">
<FolderDown className="size-4" />
<span>{stats?.installs ?? 0}</span>
<span>{stats.installs}</span>
</div>
</div>
</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 { parseSearchQuery, SearchQuery } 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,16 +285,78 @@ 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>
)}
{isChatPage || hasModules?.length !== 2 || canNavigateBack() ? null : (
{isChatPage || hasModules?.length !== 2 ? null : (
<div className="relative w-16 flex justify-end items-center">
<div className="absolute right-[52px] -top-2 z-10">
<VisibleKey

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,47 +1,19 @@
import React from "react";
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Maximize2, Minimize2, Focus } from "lucide-react";
import { useState, useEffect, useMemo } from "react";
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";
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.
@@ -184,6 +156,7 @@ const ViewExtension: React.FC = () => {
}
};
window.addEventListener("message", messageHandler);
console.info("Coco extension API listener is up");
return () => {
window.removeEventListener("message", messageHandler);
@@ -191,233 +164,15 @@ 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]);
return (
<div className="relative w-full h-full">
{isFullscreen && <div className="absolute inset-0 pointer-events-none" />}
{resizable && (
<button
aria-label={
isFullscreen
? t("viewExtension.fullscreen.exit")
: 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 {}
}
}}
>
{isFullscreen ? (
<Minimize2 className="size-4" />
) : (
<Maximize2 className="size-4" />
)}
</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>
<div
className="w-full h-full flex items-center justify-center"
onMouseDownCapture={() => {
iframeRef.current?.focus();
}}
onPointerDown={() => {
iframeRef.current?.focus();
}}
onClickCapture={() => {
iframeRef.current?.focus();
}}
>
<iframe
ref={iframeRef}
src={fileUrl}
className="border-0"
style={{
width: `${baseWidth}px`,
height: `${baseHeight}px`,
transform: `scale(${scale})`,
transformOrigin: "center center",
outline: "none",
}}
allow="fullscreen; pointer-lock; gamepad"
allowFullScreen
tabIndex={-1}
onLoad={(event) => {
event.currentTarget.focus();
try {
iframeRef.current?.contentWindow?.focus();
} catch {}
}}
/>
</div>
</div>
<iframe
src={fileUrl}
className="w-full h-full border-0"
onLoad={(event) => {
event.currentTarget.focus();
}}
/>
);
};

View File

@@ -110,13 +110,9 @@ function SearchChat({
let collapseWindowTimer = useRef<ReturnType<typeof setTimeout>>();
const setWindowSize = useCallback(() => {
const { viewExtensionOpened } = useSearchStore.getState();
if (collapseWindowTimer.current) {
clearTimeout(collapseWindowTimer.current);
}
if (viewExtensionOpened != null) {
return;
}
const width = 680;
let height = WINDOW_CENTER_BASELINE_HEIGHT;
@@ -181,28 +177,6 @@ function SearchChat({
onFocus: debouncedSetWindowSize,
});
useEffect(() => {
const unlisten = platformAdapter.listenEvent(
"refresh-window-size",
() => {
debouncedSetWindowSize();
}
);
return () => {
unlisten
.then((fn) => {
try {
typeof fn === "function" && fn();
} catch {
// ignore
}
})
.catch(() => {
// ignore
});
};
}, [debouncedSetWindowSize]);
useEffect(() => {
dispatch({
type: "SET_SEARCH_ACTIVE",
@@ -412,7 +386,7 @@ function SearchChat({
<div
data-tauri-drag-region={isTauri}
className={clsx(
"m-auto overflow-hidden relative bg-no-repeat flex flex-col bg-cover",
"m-auto overflow-hidden relative bg-no-repeat flex flex-col",
[
isTransitioned
? "bg-bottom bg-[url('/assets/chat_bg_light.png')] dark:bg-[url('/assets/chat_bg_dark.png')]"
@@ -427,6 +401,7 @@ function SearchChat({
}
)}
style={{
backgroundSize: "auto 590px",
opacity: blurred ? blurOpacity / 100 : normalOpacity / 100,
}}
>

View File

@@ -1,207 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMount } from "ahooks";
import { ShieldCheck, Monitor, Mic, RotateCcw } from "lucide-react";
import clsx from "clsx";
import platformAdapter from "@/utils/platformAdapter";
import SettingsItem from "@/components/Settings/SettingsItem";
const Permissions = () => {
const { t } = useTranslation();
const [accessibilityAuthorized, setAccessibilityAuthorized] = useState<boolean | null>(null);
const [screenAuthorized, setScreenAuthorized] = useState<boolean | null>(null);
const [microphoneAuthorized, setMicrophoneAuthorized] = useState<boolean | null>(null);
const refresh = async () => {
const [ax, sr, mic] = await Promise.all([
platformAdapter.invokeBackend<boolean>("check_accessibility_trusted"),
platformAdapter.checkScreenRecordingPermission(),
platformAdapter.checkMicrophonePermission(),
]);
console.info("[permissions] refreshed", { accessibility: ax, screenRecording: sr, microphone: mic });
setAccessibilityAuthorized(ax);
setScreenAuthorized(sr);
setMicrophoneAuthorized(mic);
};
useMount(refresh);
const openAccessibilitySettings = async () => {
const window = await platformAdapter.getCurrentWebviewWindow();
await window.setAlwaysOnTop(false);
console.info("[permissions] open accessibility settings");
await platformAdapter.invokeBackend("open_accessibility_settings");
await refresh();
};
const requestScreenRecording = async () => {
const window = await platformAdapter.getCurrentWebviewWindow();
await window.setAlwaysOnTop(false);
console.info("[permissions] request screen recording");
await platformAdapter.requestScreenRecordingPermission();
await platformAdapter.invokeBackend("open_screen_recording_settings");
await refresh();
};
const requestMicrophone = async () => {
const window = await platformAdapter.getCurrentWebviewWindow();
await window.setAlwaysOnTop(false);
console.info("[permissions] request microphone");
await platformAdapter.requestMicrophonePermission();
await platformAdapter.invokeBackend("open_microphone_settings");
await refresh();
};
const [refreshing, setRefreshing] = useState(false);
const handleRefresh = async () => {
if (refreshing) return;
setRefreshing(true);
try {
await refresh();
} finally {
setRefreshing(false);
}
};
useEffect(() => {
const unlisten1 = platformAdapter.listenEvent("selection-permission-required", async () => {
console.info("[permissions] selection-permission-required received");
await refresh();
});
const unlisten2 = platformAdapter.listenEvent("selection-permission-info", async (evt: any) => {
console.info("[permissions] selection-permission-info", evt?.payload);
await refresh();
});
return () => {
unlisten1.then((fn) => fn());
unlisten2.then((fn) => fn());
};
}, []);
return (
<>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{t("settings.advanced.permissions.title")}
</h2>
<div className="space-y-6">
<SettingsItem
icon={ShieldCheck}
title={t("settings.advanced.permissions.accessibility.title")}
description={t("settings.advanced.permissions.accessibility.description")}
>
<div className="flex items-center gap-3">
{accessibilityAuthorized ? (
<span className="text-sm font-medium text-green-600 dark:text-green-500">
{t("settings.common.status.authorized")}
</span>
) : (
<span className="text-sm font-medium text-red-600 dark:text-red-500">
{t("settings.common.status.notAuthorized")}
</span>
)}
<button
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm"
onClick={openAccessibilitySettings}
>
{t("settings.common.actions.openNow")}
</button>
<button
className={clsx(
"flex items-center justify-center size-8 rounded-[6px] border border-black/5 dark:border-white/10 transition bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100",
{ "opacity-70 cursor-not-allowed": refreshing }
)}
onClick={handleRefresh}
title={t("settings.common.actions.refresh")}
>
<RotateCcw
className={clsx("size-4", {
"animate-spin": refreshing,
})}
/>
</button>
</div>
</SettingsItem>
<SettingsItem
icon={Monitor}
title={t("settings.advanced.permissions.screenRecording.title")}
description={t("settings.advanced.permissions.screenRecording.description")}
>
<div className="flex items-center gap-3">
{screenAuthorized ? (
<span className="text-sm font-medium text-green-600 dark:text-green-500">
{t("settings.common.status.authorized")}
</span>
) : (
<span className="text-sm font-medium text-red-600 dark:text-red-500">
{t("settings.common.status.notAuthorized")}
</span>
)}
<button
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm"
onClick={requestScreenRecording}
>
{t("settings.common.actions.openNow")}
</button>
<button
className={clsx(
"flex items-center justify-center size-8 rounded-[6px] border border-black/5 dark:border-white/10 transition bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100",
{ "opacity-70 cursor-not-allowed": refreshing }
)}
onClick={handleRefresh}
title={t("settings.common.actions.refresh")}
>
<RotateCcw
className={clsx("size-4", {
"animate-spin": refreshing,
})}
/>
</button>
</div>
</SettingsItem>
<SettingsItem
icon={Mic}
title={t("settings.advanced.permissions.microphone.title")}
description={t("settings.advanced.permissions.microphone.description")}
>
<div className="flex items-center gap-3">
{microphoneAuthorized ? (
<span className="text-sm font-medium text-green-600 dark:text-green-500">
{t("settings.common.status.authorized")}
</span>
) : (
<span className="text-sm font-medium text-red-600 dark:text-red-500">
{t("settings.common.status.notAuthorized")}
</span>
)}
<button
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm"
onClick={requestMicrophone}
>
{t("settings.common.actions.openNow")}
</button>
<button
className={clsx(
"flex items-center justify-center size-8 rounded-[6px] border border-black/5 dark:border-white/10 transition bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100",
{ "opacity-70 cursor-not-allowed": refreshing }
)}
onClick={handleRefresh}
title={t("settings.common.actions.refresh")}
>
<RotateCcw
className={clsx("size-4", {
"animate-spin": refreshing,
})}
/>
</button>
</div>
</SettingsItem>
</div>
</>
);
};
export default Permissions;

View File

@@ -11,13 +11,6 @@ import {
} from "lucide-react";
import { useMount } from "ahooks";
import { isNil } from "lodash-es";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
import Shortcuts from "./components/Shortcuts";
import SettingsItem from "../SettingsItem";
@@ -30,8 +23,13 @@ import UpdateSettings from "./components/UpdateSettings";
import SettingsToggle from "../SettingsToggle";
import SelectionSettings from "./components/Selection";
import { isMac } from "@/utils/platform";
import Permissions from "./components/Permissions";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
const Advanced = () => {
const { t } = useTranslation();
@@ -198,8 +196,6 @@ const Advanced = () => {
})}
</div>
{isMac && <Permissions />}
{isMac && <SelectionSettings />}
<Shortcuts />

View File

@@ -75,10 +75,6 @@ export interface ViewExtensionUISettings {
search_bar: boolean;
filter_bar: boolean;
footer: boolean;
width: number | null;
height: number | null;
resizable: boolean;
detachable: boolean;
}
export interface Extension {

View File

@@ -14,19 +14,19 @@ export default function SettingsItem({
children,
}: SettingsItemProps) {
return (
<div className="flex items-center justify-between gap-6 min-w-0">
<div className="flex items-center space-x-3 min-w-0">
<div className="flex items-center justify-between gap-6">
<div className="flex items-center space-x-3">
<Icon className="h-5 min-w-5 text-gray-400 dark:text-gray-500" />
<div className="max-w-[680px] min-w-0">
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
{title}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 whitespace-normal break-words">
<p className="text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
</div>
</div>
<div className="flex-shrink-0">{children}</div>
{children}
</div>
);
}

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

@@ -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

@@ -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

@@ -11,9 +11,6 @@ const useEscape = () => {
const setVisibleContextMenu = useSearchStore((state) => {
return state.setVisibleContextMenu;
});
const viewExtensionOpened = useSearchStore((state) => {
return state.viewExtensionOpened;
});
useKeyPress("esc", (event) => {
event.preventDefault();
@@ -36,9 +33,6 @@ const useEscape = () => {
return closeHistoryPanel();
}
if (viewExtensionOpened != null) {
return;
}
platformAdapter.hideWindow();
});
};

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,26 @@
import { useMount } from "ahooks";
import platformAdapter from "@/utils/platformAdapter";
import { useSelectionStore } from "@/stores/selectionStore";
export default function useSelectionEnabled() {
useMount(async () => {
try {
const enabled = await platformAdapter.invokeBackend<boolean>("get_selection_enabled");
useSelectionStore.getState().setSelectionEnabled(!!enabled);
} catch (e) {
console.error("get_selection_enabled failed:", e);
}
const unlisten = await platformAdapter.listenEvent(
"selection-enabled",
({ payload }: any) => {
useSelectionStore.getState().setSelectionEnabled(!!payload?.enabled);
}
);
return () => {
unlisten && unlisten();
};
});
}

View File

@@ -18,7 +18,7 @@ export const useTray = () => {
const showCocoShortcuts = useAppStore((state) => state.showCocoShortcuts);
const selectionEnabled = useSelectionStore((state) => state.selectionEnabled);
const setSelectionEnabled = useSelectionStore((state) => state.setSelectionEnabled);
// const setSelectionEnabled = useSelectionStore((state) => state.setSelectionEnabled);
useUpdateEffect(() => {
if (showCocoShortcuts.length === 0) return;
@@ -65,18 +65,18 @@ export const useTray = () => {
itemPromises.push(PredefinedMenuItem.new({ item: "Separator" }));
if (isMac) {
itemPromises.push(
MenuItem.new({
text: selectionEnabled
? t("tray.selectionDisable")
: t("tray.selectionEnable"),
action: async () => {
setSelectionEnabled(!selectionEnabled);
},
})
);
}
// if (isMac) {
// itemPromises.push(
// MenuItem.new({
// text: selectionEnabled
// ? t("tray.selectionDisable")
// : t("tray.selectionEnable"),
// action: async () => {
// setSelectionEnabled(!selectionEnabled);
// },
// })
// );
// }
itemPromises.push(
MenuItem.new({

View File

@@ -187,21 +187,6 @@
"description": "Get early access to new features. May be unstable."
}
},
"permissions": {
"title": "Permissions",
"accessibility": {
"title": "Accessibility",
"description": "Required to read selected text in the foreground app. Grant in System Settings → Privacy & Security → Accessibility."
},
"screenRecording": {
"title": "Screen Recording",
"description": "Required for window/screen screenshots and sharing. Grant in System Settings → Privacy & Security → Screen Recording."
},
"microphone": {
"title": "Microphone",
"description": "Required for voice input and recording. Grant in System Settings → Privacy & Security → Microphone."
}
},
"other": {
"title": "Other Settings",
"connectionTimeout": {
@@ -244,16 +229,6 @@
"extensionsContent": "Extensions settings content",
"advancedContent": "Advanced Settings content"
},
"common": {
"status": {
"authorized": "Authorized",
"notAuthorized": "Not Authorized"
},
"actions": {
"openNow": "Open Settings",
"refresh": "Refresh"
}
},
"extensions": {
"title": "Extensions",
"list": {
@@ -467,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": {
@@ -651,16 +639,9 @@
},
"deleteDialog": {
"title": "Uninstall",
"description": "This will delete all data and commands related to the extension."
"description": "This will remove all the data and commands associated with this extension."
}
},
"viewExtension": {
"fullscreen": {
"enter": "Enter Full Screen",
"exit": "Exit Full Screen"
},
"focus": "Focus"
},
"deleteDialog": {
"button": {
"cancel": "Cancel",

View File

@@ -187,21 +187,6 @@
"description": "抢先体验新功能,可能不稳定。"
}
},
"permissions": {
"title": "权限设置",
"accessibility": {
"title": "辅助功能Accessibility",
"description": "用于读取前台应用的选中文本,需在「隐私与安全 → 辅助功能」中授权。"
},
"screenRecording": {
"title": "屏幕录制",
"description": "用于窗口/屏幕截图与共享,需要在「隐私与安全 → 屏幕录制」中授权。"
},
"microphone": {
"title": "麦克风",
"description": "用于语音输入与录音功能,需要在「隐私与安全 → 麦克风」中授权。"
}
},
"other": {
"title": "其它设置",
"connectionTimeout": {
@@ -244,16 +229,6 @@
"extensionsContent": "扩展设置内容",
"advancedContent": "高级设置内容"
},
"common": {
"status": {
"authorized": "已授权",
"notAuthorized": "未授权"
},
"actions": {
"openNow": "去授权",
"refresh": "刷新状态"
}
},
"extensions": {
"title": "扩展",
"list": {
@@ -467,6 +442,19 @@
"placeholder": "问更多",
"continueInChat": "继续聊天",
"copy": "复制"
},
"fuzziness": {
"fuzzyMatch": "模糊匹配"
},
"filters": {
"allTime": "全部时间",
"past7Days": "过去 7 天",
"past90Days": "过去 90 天",
"past1year": "过去 1 年",
"more": "更多",
"updateTime": "更新时间",
"selectDateRange": "选择日期范围",
"filters": "筛选"
}
},
"assistant": {
@@ -653,13 +641,6 @@
"description": "这将删除与该扩展相关的所有数据和命令。"
}
},
"viewExtension": {
"fullscreen": {
"enter": "进入全屏",
"exit": "退出全屏"
},
"focus": "聚焦"
},
"deleteDialog": {
"button": {
"cancel": "取消",

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

@@ -18,7 +18,7 @@ import { useExtensionsStore } from "@/stores/extensionsStore";
import { useSelectionStore, startSelectionStorePersistence } from "@/stores/selectionStore";
import { useServers } from "@/hooks/useServers";
import { useDeepLinkManager } from "@/hooks/useDeepLinkManager";
import { useSelectionWindow } from "@/hooks/useSelectionWindow";
// import { useSelectionWindow } from "@/hooks/useSelectionWindow";
export default function LayoutOutlet() {
const location = useLocation();
@@ -128,7 +128,7 @@ export default function LayoutOutlet() {
});
// --- Selection window ---
useSelectionWindow();
// useSelectionWindow();
return (
<>

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

@@ -33,7 +33,7 @@ export const useSelectionStore = create<SelectionStore>((set) => ({
setIconsOnly: (iconsOnly) => set({ iconsOnly }),
toolbarConfig: [],
setToolbarConfig: (toolbarConfig) => set({ toolbarConfig }),
selectionEnabled: true,
selectionEnabled: false,
setSelectionEnabled: (selectionEnabled) => set({ selectionEnabled }),
}));

View File

@@ -12,7 +12,6 @@ import { ViewExtensionOpened } from "@/stores/searchStore";
export interface EventPayloads {
"theme-changed": string;
"tauri://focus": void;
"refresh-window-size": void;
"endpoint-changed": {
endpoint: string;
endpoint_http: string;
@@ -58,14 +57,6 @@ export interface EventPayloads {
"selection-detected": string;
"selection-enabled": boolean;
"change-selection-store": any;
"selection-permission-required": boolean;
"selection-permission-info": {
bundle_id: string;
exe_path: string;
in_applications: boolean;
is_dmg: boolean;
is_dev_guess: boolean;
};
}
// Window operation interface

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;
}

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;
@@ -258,9 +268,7 @@ export const navigateBack = () => {
}
if (viewExtensionOpened) {
setViewExtensionOpened(void 0);
platformAdapter.emitEvent("refresh-window-size");
return;
return setViewExtensionOpened(void 0);
}
setSourceData(void 0);
@@ -307,10 +315,14 @@ export const visibleSearchBar = () => {
};
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;
@@ -404,3 +416,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').toISOString();
}
if (to) {
nextQueryStrings["update_time_end"] = dayjs(to).endOf('day').toISOString();
}
}
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

@@ -16,16 +16,8 @@ import { useAppearanceStore } from "@/stores/appearanceStore";
import { copyToClipboard, dispatchEvent, OpenURLWithBrowser } from ".";
import { useAppStore } from "@/stores/appStore";
import { unrequitable } from "@/utils";
import {
getCurrentWebviewWindow,
WebviewWindow,
} from "@tauri-apps/api/webviewWindow";
import {
cursorPosition,
Monitor,
monitorFromPoint,
Theme,
} from "@tauri-apps/api/window";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import { Theme } from "@tauri-apps/api/window";
export interface TauriPlatformAdapter extends BasePlatformAdapter {
openFileDialog: (
@@ -38,65 +30,13 @@ export interface TauriPlatformAdapter extends BasePlatformAdapter {
getWindowTheme: () => Promise<Theme | null>;
setWindowTheme: (theme: Theme | null) => Promise<void>;
getAllWindows: () => Promise<WebviewWindow[]>;
setWindowResizable: (resizable: boolean) => Promise<void>;
isWindowResizable: () => Promise<boolean>;
getWindowSize: () => Promise<{ width: number; height: number }>;
setWindowFullscreen: (enable: boolean) => Promise<void>;
isWindowMaximized: () => Promise<boolean>;
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>;
}
// Create Tauri adapter functions
export const createTauriAdapter = (): TauriPlatformAdapter => {
return {
async setWindowSize(width, height) {
return windowWrapper.setLogicalSize(width, height);
},
async getWindowSize() {
return windowWrapper.getLogicalSize();
},
async setWindowResizable(resizable) {
return windowWrapper.setResizable(resizable);
},
async isWindowResizable() {
return windowWrapper.isResizable();
},
async setWindowFullscreen(enable) {
return windowWrapper.setFullscreen(enable);
},
async isWindowMaximized() {
return windowWrapper.isMaximized();
},
async setWindowMaximized(enable) {
return windowWrapper.setMaximized(enable);
},
async getWindowPosition() {
return windowWrapper.getLogicalPosition();
},
async setWindowPosition(x, y) {
return windowWrapper.setLogicalPosition(x, y);
},
async centerWindow() {
return windowWrapper.center();
},
async getMonitorFromCursor() {
const appWindow = getCurrentWebviewWindow();
const factor = await appWindow.scaleFactor();
const point = await cursorPosition();
const { x, y } = point.toLogical(factor);
return monitorFromPoint(x, y);
},
async centerOnCurrentMonitor() {
return windowWrapper.centerOnMonitor();
return windowWrapper.setSize(width, height);
},
async hideWindow() {

View File

@@ -12,14 +12,6 @@ export interface WebPlatformAdapter extends BasePlatformAdapter {
getWindowTheme: () => Promise<string>;
setWindowTheme: (theme: string | null) => Promise<void>;
getAllWindows: () => Promise<any[]>;
setWindowResizable: (resizable: boolean) => Promise<void>;
isWindowResizable: () => Promise<boolean>;
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>;
}
// Create Web adapter functions
@@ -43,46 +35,6 @@ export const createWebAdapter = (): WebPlatformAdapter => {
console.log(`Web mode simulated window resize: ${width}x${height}`);
// No actual operation needed in web environment
},
async getWindowSize() {
return { width: window.innerWidth, height: window.innerHeight };
},
async setWindowResizable(resizable) {
console.log("Web mode simulated set window resizable:", resizable);
},
async isWindowResizable() {
return true;
},
async setWindowFullscreen(enable) {
console.log("Web mode simulated fullscreen:", enable);
},
async getMonitorFromCursor() {
return {
size: {
toLogical: (factor: number) => ({
width: window.innerWidth / factor,
height: window.innerHeight / factor,
}),
},
position: {
toLogical: (factor: number) => ({
x: window.screenX / factor,
y: window.screenY / factor,
}),
},
};
},
async centerOnCurrentMonitor() {
// Not applicable in web mode
return;
},
async getWindowPosition() {
return { x: window.screenX, y: window.screenY };
},
async setWindowPosition(x, y) {
console.log(`Web mode simulated set window position: ${x}, ${y}`);
},
async hideWindow() {
console.log("Web mode simulated window hide");
@@ -325,4 +277,4 @@ export const createWebAdapter = (): WebPlatformAdapter => {
return Promise.resolve();
},
};
};
};

View File

@@ -1,6 +1,5 @@
import * as commands from "@/commands";
import { WINDOW_CENTER_BASELINE_HEIGHT } from "@/constants";
import platformAdapter from "../platformAdapter";
// Window operations
export const windowWrapper = {
@@ -11,7 +10,7 @@ export const windowWrapper = {
return getCurrentWebviewWindow();
},
async setLogicalSize(width: number, height: number) {
async setSize(width: number, height: number) {
const { LogicalSize } = await import("@tauri-apps/api/dpi");
const window = await this.getCurrentWebviewWindow();
if (window) {
@@ -21,95 +20,6 @@ export const windowWrapper = {
}
}
},
async getLogicalSize() {
const window = await this.getCurrentWebviewWindow();
if (window) {
const size = await window.innerSize();
const scale = await window.scaleFactor();
return {
width: Math.round(size.width / scale),
height: Math.round(size.height / scale),
};
}
return { width: 0, height: 0 };
},
async setResizable(resizable: boolean) {
const window = await this.getCurrentWebviewWindow();
if (window) {
return window.setResizable(resizable);
}
},
async isResizable() {
const window = await this.getCurrentWebviewWindow();
if (window) {
return window.isResizable();
}
return false;
},
async setFullscreen(enable: boolean) {
const { getCurrentWindow } = await import("@tauri-apps/api/window");
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");
const window = await this.getCurrentWebviewWindow();
if (window) {
return window.setPosition(new LogicalPosition(x, y));
}
},
async getLogicalPosition() {
const window = await this.getCurrentWebviewWindow();
if (window) {
const pos = await window.outerPosition();
const scale = await window.scaleFactor();
return { x: Math.round(pos.x / scale), y: Math.round(pos.y / scale) };
}
return { x: 0, y: 0 };
},
async centerOnMonitor() {
const { PhysicalPosition } = await import("@tauri-apps/api/dpi");
const monitor = await platformAdapter.getMonitorFromCursor();
if (!monitor) return;
const window = await this.getCurrentWebviewWindow();
const { x: monitorX, y: monitorY } = monitor.position;
const { width: monitorWidth, height: monitorHeight } = monitor.size;
const windowSize = await window.innerSize();
const x = monitorX + (monitorWidth - windowSize.width) / 2;
const y = monitorY + (monitorHeight - windowSize.height) / 2;
return window.setPosition(new PhysicalPosition(x, y));
},
async isMaximized() {
const window = await this.getCurrentWebviewWindow();
if (window) {
return window.isMaximized();
}
return false;
},
async setMaximized(enable: boolean) {
const window = await this.getCurrentWebviewWindow();
if (window) {
if (enable) {
return window.maximize();
} else {
return window.unmaximize();
}
}
},
};
// Event handling