mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-21 05:49:24 +01:00
Compare commits
45 Commits
v0.10.0
...
add-search
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ab93407ed | ||
|
|
a488b0b465 | ||
|
|
5fdbc7cd31 | ||
|
|
6d00efc7e8 | ||
|
|
798bd923c6 | ||
|
|
56234526a8 | ||
|
|
5fe4032209 | ||
|
|
8d0d719964 | ||
|
|
d056271848 | ||
|
|
b11fec29dc | ||
|
|
97369963a6 | ||
|
|
f3fa91a03c | ||
|
|
0e6d7fa52f | ||
|
|
209438a638 | ||
|
|
4ada34ad75 | ||
|
|
52f6f73d53 | ||
|
|
aa779ec156 | ||
|
|
93da46662c | ||
|
|
fbbc5f1d6a | ||
|
|
e89cca1c2f | ||
|
|
5e103bfc3d | ||
|
|
78ec0836a1 | ||
|
|
c698b4094b | ||
|
|
2235bd1da1 | ||
|
|
8a2898b0b9 | ||
|
|
53f6b33279 | ||
|
|
b20cc771ea | ||
|
|
a339dbab9c | ||
|
|
1bb2d8b3b3 | ||
|
|
1ceb385505 | ||
|
|
371f8d0daa | ||
|
|
28cf5ca326 | ||
|
|
ed10be5c6f | ||
|
|
0bcb974837 | ||
|
|
4c4c08a598 | ||
|
|
724db0f66d | ||
|
|
a8a14cae18 | ||
|
|
eea6a7a5ae | ||
|
|
fdc8967b76 | ||
|
|
5df1f9668d | ||
|
|
23607e6b4c | ||
|
|
494be3db62 | ||
|
|
530502ecff | ||
|
|
8d6204a9d8 | ||
|
|
ca350dfeed |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -29,4 +29,4 @@ web.md
|
||||
*.sw?
|
||||
.env
|
||||
|
||||
.trae
|
||||
.trae
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "coco",
|
||||
"private": true,
|
||||
"version": "0.10.0",
|
||||
"version": "0.9.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -51,17 +51,19 @@
|
||||
"axios": "^1.12.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.5.0",
|
||||
"filesize": "^10.1.6",
|
||||
"i18next": "^23.16.8",
|
||||
"i18next-browser-languagedetector": "^8.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.461.0",
|
||||
"lucide-react": "^0.561.0",
|
||||
"mdast-util-gfm-autolink-literal": "2.0.0",
|
||||
"mermaid": "^11.6.0",
|
||||
"nanoid": "^5.1.5",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hotkeys-hook": "^4.6.2",
|
||||
"react-i18next": "^15.5.1",
|
||||
|
||||
52
pnpm-lock.yaml
generated
52
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
14
src-tauri/Cargo.lock
generated
14
src-tauri/Cargo.lock
generated
@@ -1132,7 +1132,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "coco"
|
||||
version = "0.10.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"actix-files",
|
||||
"actix-web",
|
||||
@@ -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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "coco"
|
||||
version = "0.10.0"
|
||||
version = "0.9.1"
|
||||
description = "Search, connect, collaborate – all in one place."
|
||||
authors = ["INFINI Labs"]
|
||||
edition = "2024"
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,162 @@ pub struct FailedRequest {
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct Aggregation {
|
||||
// Frontend code needs to this to not be NULL, so we `clean_aggregations()`
|
||||
// in query_coco_fusion() to ensure this.
|
||||
pub buckets: Option<Vec<AggBucket>>,
|
||||
}
|
||||
|
||||
/// A bucket's fields contain more than just "doc_count" and "key", but we only
|
||||
/// need them. Serde can deserialize this as we don't `deny_unknown_fields`.
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct AggBucket {
|
||||
doc_count: usize,
|
||||
key: String,
|
||||
/// Optional human label extracted from `top.hits.hits[0]._source.source.name`.
|
||||
label: Option<String>,
|
||||
}
|
||||
|
||||
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>;
|
||||
|
||||
/// 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 +287,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,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 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
2
src-tauri/src/extension/third_party/mod.rs
vendored
2
src-tauri/src/extension/third_party/mod.rs
vendored
@@ -1110,6 +1110,8 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
||||
source: self.get_type(),
|
||||
hits,
|
||||
total_hits,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use crate::common::error::{ReportErrorStyle, SearchError, report_error};
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use crate::common::search::{
|
||||
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
|
||||
Aggregations, FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
|
||||
merge_aggregations,
|
||||
};
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||
@@ -18,6 +19,20 @@ use std::sync::Arc;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tokio::time::{Duration, timeout};
|
||||
|
||||
/// Helper function to drop empty aggregations and normalize `Option` state.
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Available `query_strings`:
|
||||
///
|
||||
/// * "querysource": the query/search source to search
|
||||
@@ -40,6 +55,8 @@ pub async fn query_coco_fusion(
|
||||
query_strings: HashMap<String, String>,
|
||||
query_timeout: u64,
|
||||
) -> Result<MultiSourceQueryResponse, SearchError> {
|
||||
println!("DBG: querystrings {:?}", query_strings);
|
||||
|
||||
if query_strings.contains_key("datasource") && !query_strings.contains_key("querysource") {
|
||||
panic!("[querysource] has to be provided if [datasource] is set")
|
||||
}
|
||||
@@ -60,7 +77,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 +94,13 @@ pub async fn query_coco_fusion(
|
||||
search_query,
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
if let Ok(ref mut response) = res_response {
|
||||
clean_aggregations(&mut response.aggregations);
|
||||
}
|
||||
|
||||
res_response
|
||||
}
|
||||
|
||||
/// Query only 1 query source.
|
||||
@@ -121,6 +144,7 @@ async fn query_coco_fusion_single_query_source(
|
||||
failed: Vec::new(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
aggregations: None,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -132,6 +156,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 +169,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 +205,7 @@ async fn query_coco_fusion_single_query_source(
|
||||
failed: failed_requests,
|
||||
hits,
|
||||
total_hits,
|
||||
aggregations,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -227,6 +254,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 +268,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 +313,7 @@ async fn query_coco_fusion_multi_query_sources(
|
||||
failed: Vec::new(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
aggregations: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -423,6 +455,7 @@ async fn query_coco_fusion_multi_query_sources(
|
||||
failed: failed_requests,
|
||||
hits: final_hits,
|
||||
total_hits,
|
||||
aggregations,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,31 @@ pub struct CocoSearchSource {
|
||||
server: Server,
|
||||
}
|
||||
|
||||
/// Convert frontend query string key/value into coco server query param.
|
||||
/// Returns `None` when the key is not recognized.
|
||||
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 +124,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 +134,55 @@ 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);
|
||||
}
|
||||
}
|
||||
println!("DBG: query params\n{:?}", query_params);
|
||||
|
||||
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 +225,8 @@ impl SearchSource for CocoSearchSource {
|
||||
hits.push((document, score));
|
||||
}
|
||||
}
|
||||
|
||||
aggregations = parsed.aggregations;
|
||||
}
|
||||
|
||||
// Return the final result
|
||||
@@ -163,6 +234,7 @@ impl SearchSource for CocoSearchSource {
|
||||
source: self.get_type(),
|
||||
hits,
|
||||
total_hits,
|
||||
aggregations,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"width": 680,
|
||||
"decorations": false,
|
||||
"minimizable": false,
|
||||
"maximizable": true,
|
||||
"maximizable": false,
|
||||
"skipTaskbar": true,
|
||||
"resizable": false,
|
||||
"acceptFirstMouse": true,
|
||||
|
||||
@@ -63,7 +63,6 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
|
||||
type="text"
|
||||
id="endpoint"
|
||||
value={endpointLink}
|
||||
autoCorrect="off"
|
||||
placeholder={t("cloud.connect.serverPlaceholder")}
|
||||
onChange={onChangeEndpoint}
|
||||
className="text-[#101010] dark:text-white flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800"
|
||||
|
||||
@@ -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 || "-"}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
/>
|
||||
|
||||
@@ -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 ?? {};
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -243,7 +243,7 @@ const ExtensionDetail: FC<ExtensionDetailProps> = (props) => {
|
||||
}}
|
||||
deleteButtonProps={{
|
||||
className:
|
||||
"text-white bg-[#FF4949] hover:bg-[#FF4949] border-[#E6E6E6] dark:border-white/10",
|
||||
"!text-[#FF4949] bg-[#F8F9FA] dark:text-white dark:bg-[#202126] border-[#E6E6E6] dark:border-white/10",
|
||||
}}
|
||||
setIsOpen={setIsOpen}
|
||||
onCancel={handleCancel}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
207
src/components/Search/TimeFilter.tsx
Normal file
207
src/components/Search/TimeFilter.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useState, Fragment, useMemo } from "react";
|
||||
import { ListFilter, ChevronRight, BrushCleaning } 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, 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,
|
||||
} = useSearchStore();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dropdownMenuItems = [
|
||||
{
|
||||
key: "all-time",
|
||||
label: t("search.filers.allTime"),
|
||||
value: void 0,
|
||||
},
|
||||
{
|
||||
key: "7-day",
|
||||
label: t("search.filers.past7Days"),
|
||||
value: {
|
||||
from: dayjs().subtract(7, "day").toDate(),
|
||||
to: dayjs().toDate(),
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "90-day",
|
||||
label: t("search.filers.past90Days"),
|
||||
value: {
|
||||
from: dayjs().subtract(90, "day").toDate(),
|
||||
to: dayjs().toDate(),
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "1-year",
|
||||
label: t("search.filers.past1year"),
|
||||
value: {
|
||||
from: dayjs().subtract(1, "year").toDate(),
|
||||
to: dayjs().toDate(),
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "more",
|
||||
label: t("search.filers.more"),
|
||||
onClick: () => {
|
||||
setPopoverOpen(true);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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.filers.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;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={key}
|
||||
className="flex justify-between"
|
||||
onClick={() => {
|
||||
if (onClick) {
|
||||
onClick();
|
||||
} else {
|
||||
setFilterDateRange(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{label}</span>
|
||||
|
||||
{key === "more" && (
|
||||
<ChevronRight className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div />
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-100 p-4 text-sm">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-bold">{t("search.filers.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.filers.dateRange")}
|
||||
</div>
|
||||
<DatePickerRange
|
||||
selected={filterDateRange}
|
||||
onSelect={setFilterDateRange}
|
||||
/>
|
||||
|
||||
{aggregations &&
|
||||
Object.entries(aggregations).map(([key, value]) => {
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
<div className="pt-4 pb-2 text-[#999]">
|
||||
{upperFirst(camelCase(key))}
|
||||
</div>
|
||||
|
||||
<MultiSelect
|
||||
value={aggregateFilter?.[key] ?? []}
|
||||
placeholder={`Please select ${key}`}
|
||||
options={value.buckets.map((bucket) => ({
|
||||
label: bucket.label ?? bucket.key,
|
||||
value: bucket.key,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setAggregateFilter({
|
||||
...aggregateFilter,
|
||||
[key]: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeFilter;
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -21,8 +21,8 @@ import SettingsInput from "@/components//Settings/SettingsInput";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import UpdateSettings from "./components/UpdateSettings";
|
||||
import SettingsToggle from "../SettingsToggle";
|
||||
// import SelectionSettings from "./components/Selection";
|
||||
// import { isMac } from "@/utils/platform";
|
||||
import SelectionSettings from "./components/Selection";
|
||||
import { isMac } from "@/utils/platform";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
@@ -196,7 +196,7 @@ const Advanced = () => {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* {isMac && <SelectionSettings />} */}
|
||||
{isMac && <SelectionSettings />}
|
||||
|
||||
<Shortcuts />
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
212
src/components/ui/calendar.tsx
Normal file
212
src/components/ui/calendar.tsx
Normal 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 };
|
||||
43
src/components/ui/date-picker-range.tsx
Normal file
43
src/components/ui/date-picker-range.tsx
Normal 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.filers.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);
|
||||
@@ -1,97 +1,94 @@
|
||||
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 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
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;
|
||||
onChange?: (value: string[]) => 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, onChange } = props;
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
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 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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
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={setOpen}>
|
||||
<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>
|
||||
{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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { debounce, orderBy } from "lodash-es";
|
||||
import { debounce, fromPairs, orderBy, sortBy, toPairs } 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,
|
||||
@@ -14,6 +18,10 @@ import { useAppStore } from "@/stores/appStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(advancedFormat);
|
||||
|
||||
interface SearchState {
|
||||
isError: FailedRequest[];
|
||||
suggests: QueryHits[];
|
||||
@@ -52,6 +60,7 @@ export function useSearch() {
|
||||
});
|
||||
|
||||
const { querySourceTimeout, searchDelay } = useConnectStore();
|
||||
const { aggregateFilter, filterDateRange, fuzziness } = useSearchStore();
|
||||
|
||||
const [searchState, setSearchState] = useState<SearchState>({
|
||||
isError: [],
|
||||
@@ -61,6 +70,14 @@ export function useSearch() {
|
||||
globalItemIndexMap: {},
|
||||
});
|
||||
|
||||
const toEasySearchTime = (date: Date) => {
|
||||
return (
|
||||
dayjs(date).format("YYYY-MM-DDTHH:mm:ss.SSS") +
|
||||
"000" +
|
||||
dayjs(date).format("Z")
|
||||
);
|
||||
};
|
||||
|
||||
const handleSearchResponse = (
|
||||
response: MultiSourceQueryResponse,
|
||||
searchInput: string
|
||||
@@ -160,16 +177,51 @@ export function useSearch() {
|
||||
const performSearch = useCallback(
|
||||
async (searchInput: string) => {
|
||||
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 { fuzziness, aggregateFilter, filterDateRange } =
|
||||
useSearchStore.getState();
|
||||
|
||||
const queryStrings: Record<string, string> = {
|
||||
fuzziness: String(fuzziness),
|
||||
query: searchInput,
|
||||
};
|
||||
|
||||
if (filterDateRange) {
|
||||
const { from, to } = filterDateRange;
|
||||
|
||||
if (from) {
|
||||
queryStrings["update_time_start"] = toEasySearchTime(from);
|
||||
}
|
||||
|
||||
if (to) {
|
||||
queryStrings["update_time_end"] = toEasySearchTime(to);
|
||||
}
|
||||
}
|
||||
|
||||
if (aggregateFilter) {
|
||||
for (const [key, value] of Object.entries(aggregateFilter)) {
|
||||
if (value.length === 0) continue;
|
||||
|
||||
queryStrings[key] = `any(${value.join(",")})`;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("queryStrings", queryStrings);
|
||||
|
||||
response = await platformAdapter.commands("query_coco_fusion", {
|
||||
from: 0,
|
||||
size: 10,
|
||||
queryStrings: { query: searchInput },
|
||||
queryStrings,
|
||||
queryTimeout: querySourceTimeout,
|
||||
});
|
||||
} else {
|
||||
@@ -198,7 +250,23 @@ export function useSearch() {
|
||||
}
|
||||
}
|
||||
|
||||
//console.log("_suggest", searchInput, response);
|
||||
console.log("_suggest", searchInput, response);
|
||||
|
||||
if (isTauri) {
|
||||
const { setAggregations, setAggregateFilter } =
|
||||
useSearchStore.getState();
|
||||
|
||||
if (response?.aggregations) {
|
||||
const sortedAggregations = fromPairs(
|
||||
sortBy(toPairs(response.aggregations), ([key]) => key)
|
||||
);
|
||||
|
||||
setAggregations(sortedAggregations);
|
||||
} else {
|
||||
setAggregations(void 0);
|
||||
setAggregateFilter(void 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
@@ -216,6 +284,9 @@ export function useSearch() {
|
||||
aiOverviewCharLen,
|
||||
aiOverviewDelay,
|
||||
aiOverviewMinQuantity,
|
||||
aggregateFilter,
|
||||
filterDateRange,
|
||||
fuzziness,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -442,6 +442,19 @@
|
||||
"placeholder": "Ask More",
|
||||
"continueInChat": "Continue in chat",
|
||||
"copy": "Copy"
|
||||
},
|
||||
"fuzziness": {
|
||||
"fuzzyMatch": "Fuzzy Match"
|
||||
},
|
||||
"filers": {
|
||||
"allTime": "All Time",
|
||||
"past7Days": "Past 7 Days",
|
||||
"past90Days": "Past 90 Days",
|
||||
"past1year": "Past 1 Year",
|
||||
"more": "More",
|
||||
"dateRange": "Date Range",
|
||||
"selectDateRange": "Select Date Range",
|
||||
"filters": "Filters"
|
||||
}
|
||||
},
|
||||
"assistant": {
|
||||
@@ -626,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",
|
||||
|
||||
@@ -442,6 +442,19 @@
|
||||
"placeholder": "问更多",
|
||||
"continueInChat": "继续聊天",
|
||||
"copy": "复制"
|
||||
},
|
||||
"fuzziness": {
|
||||
"fuzzyMatch": "模糊匹配"
|
||||
},
|
||||
"filers": {
|
||||
"allTime": "全部时间",
|
||||
"past7Days": "过去 7 天",
|
||||
"past90Days": "过去 90 天",
|
||||
"past1year": "过去 1 年",
|
||||
"more": "更多",
|
||||
"dateRange": "日期范围",
|
||||
"selectDateRange": "选择日期范围",
|
||||
"filters": "筛选"
|
||||
}
|
||||
},
|
||||
"assistant": {
|
||||
@@ -628,13 +641,6 @@
|
||||
"description": "这将删除与该扩展相关的所有数据和命令。"
|
||||
}
|
||||
},
|
||||
"viewExtension": {
|
||||
"fullscreen": {
|
||||
"enter": "进入全屏",
|
||||
"exit": "退出全屏"
|
||||
},
|
||||
"focus": "聚焦"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"button": {
|
||||
"cancel": "取消",
|
||||
|
||||
@@ -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;
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
ExtensionPermission,
|
||||
ViewExtensionUISettings,
|
||||
} from "@/components/Settings/Extensions";
|
||||
import { 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]: string[];
|
||||
}
|
||||
|
||||
export type ISearchStore = {
|
||||
sourceData: any;
|
||||
setSourceData: (sourceData: any) => void;
|
||||
@@ -64,8 +70,25 @@ 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;
|
||||
};
|
||||
|
||||
export const DEFAULT_FUZZINESS = 5;
|
||||
|
||||
export const useSearchStore = create<ISearchStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
@@ -138,11 +161,29 @@ 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 });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "search-store",
|
||||
partialize: (state) => ({
|
||||
sourceData: state.sourceData,
|
||||
fuzziness: state.fuzziness,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -71,8 +71,21 @@ export interface FailedRequest {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface Aggregation {
|
||||
buckets: Array<{
|
||||
key: string;
|
||||
label?: string;
|
||||
doc_count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface Aggregations {
|
||||
[key: string]: Aggregation;
|
||||
}
|
||||
|
||||
export interface MultiSourceQueryResponse {
|
||||
failed: FailedRequest[];
|
||||
hits: QueryHits[];
|
||||
total_hits: number;
|
||||
aggregations?: Aggregations;
|
||||
}
|
||||
|
||||
@@ -258,9 +258,7 @@ export const navigateBack = () => {
|
||||
}
|
||||
|
||||
if (viewExtensionOpened) {
|
||||
setViewExtensionOpened(void 0);
|
||||
platformAdapter.emitEvent("refresh-window-size");
|
||||
return;
|
||||
return setViewExtensionOpened(void 0);
|
||||
}
|
||||
|
||||
setSourceData(void 0);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user