mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-18 12:37:45 +01:00
Compare commits
77 Commits
release_pr
...
v0.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ac0065234 | ||
|
|
31806b6057 | ||
|
|
533bfaf45b | ||
|
|
459705af70 | ||
|
|
84e556ddad | ||
|
|
b50a20c7d4 | ||
|
|
d4ccd780b2 | ||
|
|
aef934e9a2 | ||
|
|
1fb927c26b | ||
|
|
8974624b3c | ||
|
|
d99b35bf4c | ||
|
|
594d0ffe3f | ||
|
|
7b08a87766 | ||
|
|
ab5ca24270 | ||
|
|
c593b07187 | ||
|
|
c088dde749 | ||
|
|
1fdf7c499d | ||
|
|
01dfc616d4 | ||
|
|
8d7d655581 | ||
|
|
5292538dd7 | ||
|
|
bab98d4576 | ||
|
|
6067fa7029 | ||
|
|
61860b400f | ||
|
|
50518b6c21 | ||
|
|
b5d3ce9910 | ||
|
|
abac92d8d5 | ||
|
|
9ea7c9b1ff | ||
|
|
fcbc77fb5a | ||
|
|
60b34a118b | ||
|
|
3e0839f3da | ||
|
|
bd61faf660 | ||
|
|
0e48f4f71c | ||
|
|
24fe7144f8 | ||
|
|
e92eee1ecf | ||
|
|
1996298f0c | ||
|
|
c879c63b17 | ||
|
|
892fe78d03 | ||
|
|
e5860f63c7 | ||
|
|
fa9656bfd7 | ||
|
|
03954748b6 | ||
|
|
4a627cb32e | ||
|
|
3029303e95 | ||
|
|
fc7cd165a8 | ||
|
|
f267df3f71 | ||
|
|
b07707e973 | ||
|
|
6b0111b89f | ||
|
|
e029ddf2ba | ||
|
|
731cfc5bd7 | ||
|
|
cbd8dc52cd | ||
|
|
d1ad1af71a | ||
|
|
121f9c6118 | ||
|
|
770f60f30c | ||
|
|
5c92b5acab | ||
|
|
8e49455acf | ||
|
|
859def21bf | ||
|
|
6145306ee8 | ||
|
|
d0f7b7b833 | ||
|
|
f221606ae2 | ||
|
|
cd00ada3ac | ||
|
|
be6611133a | ||
|
|
9e682ceafc | ||
|
|
5510bedf7f | ||
|
|
ea34b7a404 | ||
|
|
ce94543baa | ||
|
|
89a8304b9e | ||
|
|
9652a54f08 | ||
|
|
ca71f07f3a | ||
|
|
00eb6bed2b | ||
|
|
95dc7a88d2 | ||
|
|
6aec9cbae2 | ||
|
|
4e58bc4b2c | ||
|
|
a9a4b5319c | ||
|
|
6523fef12b | ||
|
|
b8affcd4a1 | ||
|
|
595ae676b7 | ||
|
|
5c76c92c95 | ||
|
|
f03ad8a6c8 |
36
.github/workflows/frontend-ci.yml
vendored
36
.github/workflows/frontend-ci.yml
vendored
@@ -5,6 +5,8 @@ on:
|
|||||||
# Only run it when Frontend code changes
|
# Only run it when Frontend code changes
|
||||||
paths:
|
paths:
|
||||||
- 'src/**'
|
- 'src/**'
|
||||||
|
- 'tsup.config.ts'
|
||||||
|
- 'package.json'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
@@ -17,6 +19,9 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.head_ref }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -30,5 +35,36 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Switch platformAdapter to Web adapter
|
||||||
|
shell: bash
|
||||||
|
run: >
|
||||||
|
node -e "const fs=require('fs');const f='src/utils/platformAdapter.ts';
|
||||||
|
let s=fs.readFileSync(f,'utf8');
|
||||||
|
s=s.replace(/import\\s*\\{\\s*createTauriAdapter\\s*\\}\\s*from\\s*\\\"\\.\\/tauriAdapter\\\";/,'import { createWebAdapter } from \\\"./webAdapter\\\";');
|
||||||
|
s=s.replace(/let\\s+platformAdapter\\s*=\\s*createTauriAdapter\\(\\);/,'let platformAdapter = createWebAdapter();');
|
||||||
|
fs.writeFileSync(f,s);"
|
||||||
|
|
||||||
|
- name: Build web (Tauri dependency check)
|
||||||
|
run: pnpm build:web
|
||||||
|
|
||||||
|
- name: Verify no Tauri refs in web output
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if grep -R -n -E '@tauri-apps|tauri-plugin' out/search-chat; then
|
||||||
|
echo 'Tauri references found in web build output';
|
||||||
|
exit 1;
|
||||||
|
else
|
||||||
|
echo 'No Tauri references found';
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Restore platformAdapter to Tauri adapter
|
||||||
|
shell: bash
|
||||||
|
run: >
|
||||||
|
node -e "const fs=require('fs');const f='src/utils/platformAdapter.ts';
|
||||||
|
let s=fs.readFileSync(f,'utf8');
|
||||||
|
s=s.replace(/import\\s*\\{\\s*createWebAdapter\\s*\\}\\s*from\\s*\\\"\\.\\/webAdapter\\\";/,'import { createTauriAdapter } from \\\"./tauriAdapter\\\";');
|
||||||
|
s=s.replace(/let\\s+platformAdapter\\s*=\\s*createWebAdapter\\(\\);/,'let platformAdapter = createTauriAdapter();');
|
||||||
|
fs.writeFileSync(f,s);"
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|||||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -110,10 +110,10 @@ jobs:
|
|||||||
# On Windows, we need to generate bindings for 'searchapi.h' using bindgen.
|
# On Windows, we need to generate bindings for 'searchapi.h' using bindgen.
|
||||||
# And bindgen relies on 'libclang'
|
# And bindgen relies on 'libclang'
|
||||||
# https://rust-lang.github.io/rust-bindgen/requirements.html#windows
|
# https://rust-lang.github.io/rust-bindgen/requirements.html#windows
|
||||||
- name: Install dependencies (Windows only)
|
#
|
||||||
if: startsWith(matrix.platform, 'windows-latest')
|
# We don't need to install it because it is already included in GitHub
|
||||||
shell: bash
|
# Action runner image:
|
||||||
run: winget install LLVM.LLVM --silent --accept-package-agreements --accept-source-agreements
|
# https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-Readme.md#language-and-runtime
|
||||||
|
|
||||||
|
|
||||||
- name: Add Rust build target
|
- name: Add Rust build target
|
||||||
|
|||||||
8
.github/workflows/rust_code_check.yml
vendored
8
.github/workflows/rust_code_check.yml
vendored
@@ -35,10 +35,10 @@ jobs:
|
|||||||
# On Windows, we need to generate bindings for 'searchapi.h' using bindgen.
|
# On Windows, we need to generate bindings for 'searchapi.h' using bindgen.
|
||||||
# And bindgen relies on 'libclang'
|
# And bindgen relies on 'libclang'
|
||||||
# https://rust-lang.github.io/rust-bindgen/requirements.html#windows
|
# https://rust-lang.github.io/rust-bindgen/requirements.html#windows
|
||||||
- name: Install dependencies (Windows only)
|
#
|
||||||
if: startsWith(matrix.platform, 'windows-latest')
|
# We don't need to install it because it is already included in GitHub
|
||||||
shell: bash
|
# Action runner image:
|
||||||
run: winget install LLVM.LLVM --silent --accept-package-agreements --accept-source-agreements
|
# https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-Readme.md#language-and-runtime
|
||||||
|
|
||||||
- name: Add pizza engine as a dependency
|
- name: Add pizza engine as a dependency
|
||||||
working-directory: src-tauri
|
working-directory: src-tauri
|
||||||
|
|||||||
22
components.json
Normal file
22
components.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/main.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ Coco AI is a fully open-source, cross-platform unified search and productivity t
|
|||||||
|
|
||||||
{{% load-img "/img/coco-preview.gif" "" %}}
|
{{% load-img "/img/coco-preview.gif" "" %}}
|
||||||
|
|
||||||
For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-app/](https://docs.infinilabs.com/coco-app/).
|
For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-server/](https://docs.infinilabs.com/coco-server/).
|
||||||
|
|
||||||
## Community
|
## Community
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,68 @@ title: "Release Notes"
|
|||||||
Information about release notes of Coco App is provided here.
|
Information about release notes of Coco App is provided here.
|
||||||
|
|
||||||
## Latest (In development)
|
## Latest (In development)
|
||||||
|
|
||||||
### ❌ Breaking changes
|
### ❌ Breaking changes
|
||||||
|
|
||||||
### 🚀 Features
|
### 🚀 Features
|
||||||
|
|
||||||
### 🐛 Bug fix
|
### 🐛 Bug fix
|
||||||
|
|
||||||
|
- fix: search_extension should not panic when ext is not found #983
|
||||||
|
- fix: persist configuration settings properly #987
|
||||||
|
|
||||||
### ✈️ Improvements
|
### ✈️ Improvements
|
||||||
|
|
||||||
|
## 0.9.0 (2025-11-19)
|
||||||
|
|
||||||
|
### ❌ Breaking changes
|
||||||
|
|
||||||
|
### 🚀 Features
|
||||||
|
|
||||||
|
- feat: support switching groups via keyboard shortcuts #911
|
||||||
|
- feat: support opening logs from about page #915
|
||||||
|
- feat: support moving cursor with home and end keys #918
|
||||||
|
- feat: support pageup/pagedown to navigate search results #920
|
||||||
|
- feat: standardize multi-level menu label structure #925
|
||||||
|
- feat(View Extension): page field now accepts HTTP(s) links #925
|
||||||
|
- feat: return sub-exts when extension type exts themselves are matched #928
|
||||||
|
- feat: open quick ai with modifier key + enter #939
|
||||||
|
- feat: allow navigate back when cursor is at the beginning #940
|
||||||
|
- feat(extension compatibility): minimum_coco_version #946
|
||||||
|
- feat: add compact mode for window #947
|
||||||
|
- feat: advanced settings search debounce & local query source weight #950
|
||||||
|
- feat: add window opacity configuration option #963
|
||||||
|
- feat: add auto collapse delay for compact mode #981
|
||||||
|
|
||||||
|
### 🐛 Bug fix
|
||||||
|
|
||||||
|
- fix: automatic update of service list #913
|
||||||
|
- fix: duplicate chat content #916
|
||||||
|
- fix: resolve pinned window shortcut not working #917
|
||||||
|
- fix: WM ext does not work when operating focused win from another display #919
|
||||||
|
- fix(Window Management): Next/Previous Desktop do not work #926
|
||||||
|
- fix: fix page rapidly flickering issue #935
|
||||||
|
- fix(view extension): broken search bar UI when opening extensions via hotkey #938
|
||||||
|
- fix: allow deletion after selecting all text #943
|
||||||
|
- fix: prevent shaking when switching between chat and search pages #955
|
||||||
|
- fix: prevent duplicate login success messages #977
|
||||||
|
- fix: fix quick ai not continuing conversation #979
|
||||||
|
|
||||||
|
### ✈️ Improvements
|
||||||
|
|
||||||
|
- refactor: improve sorting logic of search results #910
|
||||||
|
- style: add dark drop shadow to images #912
|
||||||
|
- chore: add cross-domain configuration for web component #921
|
||||||
|
- refactor: retry if AXUIElementSetAttributeValue() does not work #924
|
||||||
|
- refactor(calculator): skip evaluation if expr is in form "num => num" #929
|
||||||
|
- chore: use a custom log directory #930
|
||||||
|
- chore: bump tauri_nspanel to v2.1 #933
|
||||||
|
- refactor: show_coco/hide_coco now use NSPanel's function on macOS #933
|
||||||
|
- refactor: procedure that convert_pages() into a func #934
|
||||||
|
- refactor(post-search): collect at least 2 documents from each query source #948
|
||||||
|
- refactor: custom_version_comparator() now compares semantic versions #941
|
||||||
|
- chore: center the main window vertically #959
|
||||||
|
- refactor(view extension): load HTML/resources via local HTTP server #973
|
||||||
|
|
||||||
## 0.8.0 (2025-09-28)
|
## 0.8.0 (2025-09-28)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "coco",
|
"name": "coco",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.2",
|
"@headlessui/react": "^2.2.2",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@tauri-apps/api": "^2.5.0",
|
"@tauri-apps/api": "^2.5.0",
|
||||||
"@tauri-apps/plugin-autostart": "~2.2.0",
|
"@tauri-apps/plugin-autostart": "~2.2.0",
|
||||||
"@tauri-apps/plugin-deep-link": "^2.2.1",
|
"@tauri-apps/plugin-deep-link": "^2.2.1",
|
||||||
@@ -34,7 +35,8 @@
|
|||||||
"@tauri-apps/plugin-window": "2.0.0-alpha.1",
|
"@tauri-apps/plugin-window": "2.0.0-alpha.1",
|
||||||
"@wavesurfer/react": "^1.0.11",
|
"@wavesurfer/react": "^1.0.11",
|
||||||
"ahooks": "^3.8.4",
|
"ahooks": "^3.8.4",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.12.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
@@ -59,6 +61,7 @@
|
|||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tauri-plugin-fs-pro-api": "^2.4.0",
|
"tauri-plugin-fs-pro-api": "^2.4.0",
|
||||||
"tauri-plugin-macos-permissions-api": "^2.3.0",
|
"tauri-plugin-macos-permissions-api": "^2.3.0",
|
||||||
"tauri-plugin-screenshots-api": "^2.2.0",
|
"tauri-plugin-screenshots-api": "^2.2.0",
|
||||||
|
|||||||
1079
pnpm-lock.yaml
generated
1079
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
394
src-tauri/Cargo.lock
generated
394
src-tauri/Cargo.lock
generated
@@ -2,6 +2,212 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "actix-codec"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.9.4",
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"memchr",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "actix-files"
|
||||||
|
version = "0.6.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c0d87f10d70e2948ad40e8edea79c8e77c6c66e0250a4c1f09b690465199576"
|
||||||
|
dependencies = [
|
||||||
|
"actix-http",
|
||||||
|
"actix-service",
|
||||||
|
"actix-utils",
|
||||||
|
"actix-web",
|
||||||
|
"bitflags 2.9.4",
|
||||||
|
"bytes",
|
||||||
|
"derive_more 2.0.1",
|
||||||
|
"futures-core",
|
||||||
|
"http-range",
|
||||||
|
"log",
|
||||||
|
"mime",
|
||||||
|
"mime_guess",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"v_htmlescape",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "actix-http"
|
||||||
|
version = "3.11.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7926860314cbe2fb5d1f13731e387ab43bd32bca224e82e6e2db85de0a3dba49"
|
||||||
|
dependencies = [
|
||||||
|
"actix-codec",
|
||||||
|
"actix-rt",
|
||||||
|
"actix-service",
|
||||||
|
"actix-utils",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bitflags 2.9.4",
|
||||||
|
"brotli",
|
||||||
|
"bytes",
|
||||||
|
"bytestring",
|
||||||
|
"derive_more 2.0.1",
|
||||||
|
"encoding_rs",
|
||||||
|
"flate2",
|
||||||
|
"foldhash",
|
||||||
|
"futures-core",
|
||||||
|
"h2 0.3.27",
|
||||||
|
"http 0.2.12",
|
||||||
|
"httparse",
|
||||||
|
"httpdate",
|
||||||
|
"itoa",
|
||||||
|
"language-tags",
|
||||||
|
"local-channel",
|
||||||
|
"mime",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"sha1",
|
||||||
|
"smallvec",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
"zstd",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "actix-macros"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.106",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "actix-router"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8"
|
||||||
|
dependencies = [
|
||||||
|
"bytestring",
|
||||||
|
"cfg-if",
|
||||||
|
"http 0.2.12",
|
||||||
|
"regex",
|
||||||
|
"regex-lite",
|
||||||
|
"serde",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "actix-rt"
|
||||||
|
version = "2.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "actix-server"
|
||||||
|
version = "2.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502"
|
||||||
|
dependencies = [
|
||||||
|
"actix-rt",
|
||||||
|
"actix-service",
|
||||||
|
"actix-utils",
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"mio 1.0.4",
|
||||||
|
"socket2 0.5.10",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "actix-service"
|
||||||
|
version = "2.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "actix-utils"
|
||||||
|
version = "3.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8"
|
||||||
|
dependencies = [
|
||||||
|
"local-waker",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "actix-web"
|
||||||
|
version = "4.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a597b77b5c6d6a1e1097fddde329a83665e25c5437c696a3a9a4aa514a614dea"
|
||||||
|
dependencies = [
|
||||||
|
"actix-codec",
|
||||||
|
"actix-http",
|
||||||
|
"actix-macros",
|
||||||
|
"actix-router",
|
||||||
|
"actix-rt",
|
||||||
|
"actix-server",
|
||||||
|
"actix-service",
|
||||||
|
"actix-utils",
|
||||||
|
"actix-web-codegen",
|
||||||
|
"bytes",
|
||||||
|
"bytestring",
|
||||||
|
"cfg-if",
|
||||||
|
"cookie 0.16.2",
|
||||||
|
"derive_more 2.0.1",
|
||||||
|
"encoding_rs",
|
||||||
|
"foldhash",
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"impl-more",
|
||||||
|
"itoa",
|
||||||
|
"language-tags",
|
||||||
|
"log",
|
||||||
|
"mime",
|
||||||
|
"once_cell",
|
||||||
|
"pin-project-lite",
|
||||||
|
"regex",
|
||||||
|
"regex-lite",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"smallvec",
|
||||||
|
"socket2 0.5.10",
|
||||||
|
"time",
|
||||||
|
"tracing",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "actix-web-codegen"
|
||||||
|
version = "4.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8"
|
||||||
|
dependencies = [
|
||||||
|
"actix-router",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.106",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "addr2line"
|
name = "addr2line"
|
||||||
version = "0.24.2"
|
version = "0.24.2"
|
||||||
@@ -668,6 +874,15 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytestring"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bzip2"
|
name = "bzip2"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@@ -869,8 +1084,10 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coco"
|
name = "coco"
|
||||||
version = "0.8.0"
|
version = "0.9.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"actix-files",
|
||||||
|
"actix-web",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"applications",
|
"applications",
|
||||||
"async-recursion",
|
"async-recursion",
|
||||||
@@ -976,7 +1193,7 @@ dependencies = [
|
|||||||
"block",
|
"block",
|
||||||
"cocoa-foundation",
|
"cocoa-foundation",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics 0.24.0",
|
"core-graphics",
|
||||||
"foreign-types 0.5.0",
|
"foreign-types 0.5.0",
|
||||||
"libc",
|
"libc",
|
||||||
"objc",
|
"objc",
|
||||||
@@ -1058,6 +1275,17 @@ version = "0.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
|
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cookie"
|
||||||
|
version = "0.16.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb"
|
||||||
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
|
"time",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cookie"
|
name = "cookie"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -1075,7 +1303,7 @@ version = "0.21.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9"
|
checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cookie",
|
"cookie 0.18.1",
|
||||||
"document-features",
|
"document-features",
|
||||||
"idna",
|
"idna",
|
||||||
"log",
|
"log",
|
||||||
@@ -1126,19 +1354,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "core-graphics"
|
|
||||||
version = "0.25.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.9.4",
|
|
||||||
"core-foundation 0.10.1",
|
|
||||||
"core-graphics-types",
|
|
||||||
"foreign-types 0.5.0",
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-graphics-types"
|
name = "core-graphics-types"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -1561,7 +1776,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "67fd9ae1736d6ebb2e472740fbee86fb2178b8d56feb98a6751411d4c95b7e72"
|
checksum = "67fd9ae1736d6ebb2e472740fbee86fb2178b8d56feb98a6751411d4c95b7e72"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cocoa",
|
"cocoa",
|
||||||
"core-graphics 0.24.0",
|
"core-graphics",
|
||||||
"dunce",
|
"dunce",
|
||||||
"gdk",
|
"gdk",
|
||||||
"gdkx11",
|
"gdkx11",
|
||||||
@@ -1656,7 +1871,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "0cf6f550bbbdd5fe66f39d429cb2604bcdacbf00dca0f5bbe2e9306a0009b7c6"
|
checksum = "0cf6f550bbbdd5fe66f39d429cb2604bcdacbf00dca0f5bbe2e9306a0009b7c6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics 0.24.0",
|
"core-graphics",
|
||||||
"foreign-types-shared 0.3.1",
|
"foreign-types-shared 0.3.1",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
@@ -1912,6 +2127,12 @@ version = "1.0.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foldhash"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -2662,6 +2883,25 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "0.3.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"fnv",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"futures-util",
|
||||||
|
"http 0.2.12",
|
||||||
|
"indexmap 2.11.4",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -2892,7 +3132,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"h2",
|
"h2 0.4.12",
|
||||||
"http 1.3.1",
|
"http 1.3.1",
|
||||||
"http-body 1.0.1",
|
"http-body 1.0.1",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -2955,7 +3195,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2 0.6.0",
|
||||||
"system-configuration",
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -3150,6 +3390,12 @@ version = "1.11.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408"
|
checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "impl-more"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "1.9.3"
|
version = "1.9.3"
|
||||||
@@ -3451,6 +3697,12 @@ dependencies = [
|
|||||||
"selectors 0.24.0",
|
"selectors 0.24.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "language-tags"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -3610,6 +3862,23 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "local-channel"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"local-waker",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "local-waker"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
version = "0.4.13"
|
version = "0.4.13"
|
||||||
@@ -4126,17 +4395,6 @@ dependencies = [
|
|||||||
"malloc_buf",
|
"malloc_buf",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "objc-foundation"
|
|
||||||
version = "0.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
|
|
||||||
dependencies = [
|
|
||||||
"block",
|
|
||||||
"objc",
|
|
||||||
"objc_id",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc-sys"
|
name = "objc-sys"
|
||||||
version = "0.3.5"
|
version = "0.3.5"
|
||||||
@@ -4479,15 +4737,6 @@ dependencies = [
|
|||||||
"objc2-security",
|
"objc2-security",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "objc_id"
|
|
||||||
version = "0.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
|
|
||||||
dependencies = [
|
|
||||||
"objc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "object"
|
name = "object"
|
||||||
version = "0.36.7"
|
version = "0.36.7"
|
||||||
@@ -4709,6 +4958,12 @@ version = "1.0.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pastey"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "path-clean"
|
name = "path-clean"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -5214,7 +5469,7 @@ dependencies = [
|
|||||||
"quinn-udp",
|
"quinn-udp",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
"socket2",
|
"socket2 0.6.0",
|
||||||
"thiserror 2.0.16",
|
"thiserror 2.0.16",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -5251,7 +5506,7 @@ dependencies = [
|
|||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2",
|
"socket2 0.6.0",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
@@ -5537,6 +5792,12 @@ dependencies = [
|
|||||||
"regex-syntax",
|
"regex-syntax",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-lite"
|
||||||
|
version = "0.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-syntax"
|
name = "regex-syntax"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@@ -5560,12 +5821,12 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cookie",
|
"cookie 0.18.1",
|
||||||
"cookie_store",
|
"cookie_store",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2",
|
"h2 0.4.12",
|
||||||
"http 1.3.1",
|
"http 1.3.1",
|
||||||
"http-body 1.0.1",
|
"http-body 1.0.1",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@@ -6261,6 +6522,16 @@ version = "1.15.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "socket2"
|
||||||
|
version = "0.5.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socket2"
|
name = "socket2"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@@ -6279,7 +6550,7 @@ checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"core-graphics 0.24.0",
|
"core-graphics",
|
||||||
"foreign-types 0.5.0",
|
"foreign-types 0.5.0",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
@@ -6527,7 +6798,7 @@ dependencies = [
|
|||||||
"bitflags 2.9.4",
|
"bitflags 2.9.4",
|
||||||
"block2 0.6.1",
|
"block2 0.6.1",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics 0.24.0",
|
"core-graphics",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dispatch",
|
"dispatch",
|
||||||
"dlopen2",
|
"dlopen2",
|
||||||
@@ -6606,7 +6877,7 @@ checksum = "d4d1d3b3dc4c101ac989fd7db77e045cc6d91a25349cd410455cb5c57d510c1c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cookie",
|
"cookie 0.18.1",
|
||||||
"dirs 6.0.0",
|
"dirs 6.0.0",
|
||||||
"dunce",
|
"dunce",
|
||||||
"embed_plist",
|
"embed_plist",
|
||||||
@@ -6727,17 +6998,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-nspanel"
|
name = "tauri-nspanel"
|
||||||
version = "2.0.1"
|
version = "2.1.0"
|
||||||
source = "git+https://github.com/ahkohd/tauri-nspanel?branch=v2#18ffb9a201fbf6fedfaa382fd4b92315ea30ab1a"
|
source = "git+https://github.com/ahkohd/tauri-nspanel?branch=v2.1#da9c9a8d4eb7f0524a2508988df1a7d9585b4904"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.9.4",
|
"objc2 0.6.2",
|
||||||
"block",
|
"objc2-app-kit 0.3.1",
|
||||||
"cocoa",
|
"objc2-foundation 0.3.1",
|
||||||
"core-foundation 0.10.1",
|
"pastey",
|
||||||
"core-graphics 0.25.0",
|
|
||||||
"objc",
|
|
||||||
"objc-foundation",
|
|
||||||
"objc_id",
|
|
||||||
"tauri",
|
"tauri",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -7122,7 +7389,7 @@ version = "2.8.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d4cfc9ad45b487d3fded5a4731a567872a4812e9552e3964161b08edabf93846"
|
checksum = "d4cfc9ad45b487d3fded5a4731a567872a4812e9552e3964161b08edabf93846"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cookie",
|
"cookie 0.18.1",
|
||||||
"dpi",
|
"dpi",
|
||||||
"gtk",
|
"gtk",
|
||||||
"http 1.3.1",
|
"http 1.3.1",
|
||||||
@@ -7376,7 +7643,7 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
"slab",
|
"slab",
|
||||||
"socket2",
|
"socket2 0.6.0",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
@@ -7598,6 +7865,7 @@ version = "0.1.41"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
|
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"log",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tracing-attributes",
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
@@ -7886,6 +8154,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "v_htmlescape"
|
||||||
|
version = "0.15.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "value-bag"
|
name = "value-bag"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -9009,7 +9283,7 @@ checksum = "31f0e9642a0d061f6236c54ccae64c2722a7879ad4ec7dff59bd376d446d8e90"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"block2 0.6.1",
|
"block2 0.6.1",
|
||||||
"cookie",
|
"cookie 0.18.1",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dirs 6.0.0",
|
"dirs 6.0.0",
|
||||||
"dpi",
|
"dpi",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "coco"
|
name = "coco"
|
||||||
version = "0.8.0"
|
version = "0.9.0"
|
||||||
description = "Search, connect, collaborate – all in one place."
|
description = "Search, connect, collaborate – all in one place."
|
||||||
authors = ["INFINI Labs"]
|
authors = ["INFINI Labs"]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
@@ -117,12 +117,14 @@ urlencoding = "2.1.3"
|
|||||||
scraper = "0.17"
|
scraper = "0.17"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
path-clean = "1.0.1"
|
path-clean = "1.0.1"
|
||||||
|
actix-files = "0.6.8"
|
||||||
|
actix-web = "4.11.0"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.23.0"
|
tempfile = "3.23.0"
|
||||||
|
|
||||||
[target."cfg(target_os = \"macos\")".dependencies]
|
[target."cfg(target_os = \"macos\")".dependencies]
|
||||||
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
|
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" }
|
||||||
objc2-app-kit = { version = "0.3.1", features = ["NSWindow"] }
|
objc2-app-kit = { version = "0.3.1", features = ["NSWindow"] }
|
||||||
objc2 = "0.6.2"
|
objc2 = "0.6.2"
|
||||||
objc2-core-foundation = {version = "0.3.1", features = ["CFString", "CFCGTypes", "CFArray"] }
|
objc2-core-foundation = {version = "0.3.1", features = ["CFString", "CFCGTypes", "CFArray"] }
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use crate::extension::built_in::window_management::actions::Action;
|
use crate::extension::built_in::window_management::actions::Action;
|
||||||
use crate::extension::{ExtensionPermission, ExtensionSettings};
|
use crate::extension::view_extension::serve_files_in;
|
||||||
|
use crate::extension::{ExtensionPermission, ExtensionSettings, ViewExtensionUISettings};
|
||||||
|
use log::debug;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value as Json;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
@@ -85,10 +88,15 @@ pub(crate) enum ExtensionOnOpenedType {
|
|||||||
open_with: Option<String>,
|
open_with: Option<String>,
|
||||||
},
|
},
|
||||||
View {
|
View {
|
||||||
|
/// Extension name
|
||||||
|
name: String,
|
||||||
|
// An absolute path to the extension icon or a font code.
|
||||||
|
icon: String,
|
||||||
/// Path to the HTML file that coco will load and render.
|
/// Path to the HTML file that coco will load and render.
|
||||||
///
|
///
|
||||||
/// It should be an absolute path or Tauri cannot open it.
|
/// It should be an absolute path or Tauri cannot open it.
|
||||||
page: String,
|
page: String,
|
||||||
|
ui: Option<ViewExtensionUISettings>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +126,12 @@ impl OnOpened {
|
|||||||
// The URL of a quicklink is nearly useless without such dynamic user
|
// The URL of a quicklink is nearly useless without such dynamic user
|
||||||
// inputs, so until we have dynamic URL support, we just use "N/A".
|
// inputs, so until we have dynamic URL support, we just use "N/A".
|
||||||
ExtensionOnOpenedType::Quicklink { .. } => String::from("N/A"),
|
ExtensionOnOpenedType::Quicklink { .. } => String::from("N/A"),
|
||||||
ExtensionOnOpenedType::View { page: _ } => {
|
ExtensionOnOpenedType::View {
|
||||||
|
name: _,
|
||||||
|
icon: _,
|
||||||
|
page: _,
|
||||||
|
ui: _,
|
||||||
|
} => {
|
||||||
// We currently don't have URL for this kind of extension.
|
// We currently don't have URL for this kind of extension.
|
||||||
String::from("N/A")
|
String::from("N/A")
|
||||||
}
|
}
|
||||||
@@ -132,7 +145,7 @@ impl OnOpened {
|
|||||||
pub(crate) async fn open(
|
pub(crate) async fn open(
|
||||||
tauri_app_handle: AppHandle,
|
tauri_app_handle: AppHandle,
|
||||||
on_opened: OnOpened,
|
on_opened: OnOpened,
|
||||||
extra_args: Option<HashMap<String, String>>,
|
extra_args: Option<HashMap<String, Json>>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
use crate::util::open as homemade_tauri_shell_open;
|
use crate::util::open as homemade_tauri_shell_open;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
@@ -231,22 +244,49 @@ pub(crate) async fn open(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ExtensionOnOpenedType::View { page } => {
|
ExtensionOnOpenedType::View {
|
||||||
|
name,
|
||||||
|
icon,
|
||||||
|
page,
|
||||||
|
ui,
|
||||||
|
} => {
|
||||||
|
let page_path = Utf8Path::new(&page);
|
||||||
|
let directory = page_path.parent().unwrap_or_else(|| {
|
||||||
|
panic!("View extension page path should have a parent, i.e., it should be under a directory, but [{}] does not", page);
|
||||||
|
});
|
||||||
|
let mut url = serve_files_in(directory.as_ref()).await;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Emit an event to let the frontend code open this extension.
|
* Emit an event to let the frontend code open this extension.
|
||||||
*
|
*
|
||||||
* Payload `page_and_permission` contains the information needed
|
* Payload `view_extension_opened` contains the information needed
|
||||||
* to do that.
|
* to do that.
|
||||||
*
|
*
|
||||||
* See "src/pages/main/index.tsx" for more info.
|
* See "src/pages/main/index.tsx" for more info.
|
||||||
*/
|
*/
|
||||||
|
use camino::Utf8Path;
|
||||||
use serde_json::Value as Json;
|
use serde_json::Value as Json;
|
||||||
use serde_json::to_value;
|
use serde_json::to_value;
|
||||||
|
|
||||||
let page_and_permission: [Json; 2] =
|
let html_filename = page_path
|
||||||
[Json::String(page), to_value(permission).unwrap()];
|
.file_name()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
panic!("View extension page path should have a file name, but [{}] does not have one", page);
|
||||||
|
}).to_string();
|
||||||
|
url.push('/');
|
||||||
|
url.push_str(&html_filename);
|
||||||
|
|
||||||
|
let html_file_url = url;
|
||||||
|
debug!("View extension listening on: {}", html_file_url);
|
||||||
|
let view_extension_opened: [Json; 5] = [
|
||||||
|
Json::String(name),
|
||||||
|
Json::String(icon),
|
||||||
|
Json::String(html_file_url),
|
||||||
|
to_value(permission).unwrap(),
|
||||||
|
to_value(ui).unwrap(),
|
||||||
|
];
|
||||||
tauri_app_handle
|
tauri_app_handle
|
||||||
.emit("open_view_extension", page_and_permission)
|
.emit("open_view_extension", view_extension_opened)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ impl SearchQuery {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize, Hash, PartialEq, Eq)]
|
||||||
pub struct QuerySource {
|
pub struct QuerySource {
|
||||||
pub r#type: String, //coco-server/local/ etc.
|
pub r#type: String, //coco-server/local/ etc.
|
||||||
pub id: String, //coco server's id
|
pub id: String, //coco server's id
|
||||||
|
|||||||
@@ -1227,6 +1227,7 @@ pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>,
|
|||||||
name,
|
name,
|
||||||
platforms: None,
|
platforms: None,
|
||||||
developer: None,
|
developer: None,
|
||||||
|
minimum_coco_version: None,
|
||||||
// Leave it empty as it won't be used
|
// Leave it empty as it won't be used
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
icon: icon_path,
|
icon: icon_path,
|
||||||
@@ -1242,6 +1243,7 @@ pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>,
|
|||||||
enabled,
|
enabled,
|
||||||
settings: None,
|
settings: None,
|
||||||
page: None,
|
page: None,
|
||||||
|
ui: None,
|
||||||
permission: None,
|
permission: None,
|
||||||
screenshots: None,
|
screenshots: None,
|
||||||
url: None,
|
url: None,
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ impl SearchSource for CalculatorSource {
|
|||||||
// will only be evaluated against non-whitespace characters.
|
// will only be evaluated against non-whitespace characters.
|
||||||
let query_string = query_string.trim();
|
let query_string = query_string.trim();
|
||||||
|
|
||||||
if query_string.is_empty() || query_string.len() == 1 {
|
if query_string.is_empty() {
|
||||||
return Ok(QueryResponse {
|
return Ok(QueryResponse {
|
||||||
source: self.get_type(),
|
source: self.get_type(),
|
||||||
hits: Vec::new(),
|
hits: Vec::new(),
|
||||||
@@ -150,6 +150,26 @@ impl SearchSource for CalculatorSource {
|
|||||||
let query_source = self.get_type();
|
let query_source = self.get_type();
|
||||||
let base_score = self.base_score;
|
let base_score = self.base_score;
|
||||||
let closure = move || -> QueryResponse {
|
let closure = move || -> QueryResponse {
|
||||||
|
let Ok(tokens) = meval::tokenizer::tokenize(&query_string_clone) else {
|
||||||
|
// Invalid expression, return nothing.
|
||||||
|
return QueryResponse {
|
||||||
|
source: query_source,
|
||||||
|
hits: Vec::new(),
|
||||||
|
total_hits: 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
// If it is only a number, no need to evaluate it as the result is
|
||||||
|
// this number.
|
||||||
|
// Actually, there is no need to return the result back to the users
|
||||||
|
// in such case because letting them know "x = x" is meaningless.
|
||||||
|
if tokens.len() == 1 && matches!(tokens[0], meval::tokenizer::Token::Number(_)) {
|
||||||
|
return QueryResponse {
|
||||||
|
source: query_source,
|
||||||
|
hits: Vec::new(),
|
||||||
|
total_hits: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let res_num = meval::eval_str(&query_string_clone);
|
let res_num = meval::eval_str(&query_string_clone);
|
||||||
|
|
||||||
match res_num {
|
match res_num {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use std::ffi::c_ushort;
|
|||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
use std::ops::Deref;
|
use std::ops::Deref;
|
||||||
use std::ptr::NonNull;
|
use std::ptr::NonNull;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use objc2::MainThreadMarker;
|
use objc2::MainThreadMarker;
|
||||||
use objc2_app_kit::NSEvent;
|
use objc2_app_kit::NSEvent;
|
||||||
@@ -34,6 +35,7 @@ use objc2_core_graphics::CGEventType;
|
|||||||
use objc2_core_graphics::CGMouseButton;
|
use objc2_core_graphics::CGMouseButton;
|
||||||
use objc2_core_graphics::CGRectGetMidX;
|
use objc2_core_graphics::CGRectGetMidX;
|
||||||
use objc2_core_graphics::CGRectGetMinY;
|
use objc2_core_graphics::CGRectGetMinY;
|
||||||
|
use objc2_core_graphics::CGRectIntersectsRect;
|
||||||
use objc2_core_graphics::CGWindowID;
|
use objc2_core_graphics::CGWindowID;
|
||||||
|
|
||||||
use super::error::Error;
|
use super::error::Error;
|
||||||
@@ -46,12 +48,7 @@ use std::collections::HashMap;
|
|||||||
use std::sync::{LazyLock, Mutex};
|
use std::sync::{LazyLock, Mutex};
|
||||||
|
|
||||||
fn intersects(r1: CGRect, r2: CGRect) -> bool {
|
fn intersects(r1: CGRect, r2: CGRect) -> bool {
|
||||||
let overlapping = !(r1.origin.x + r1.size.width < r2.origin.x
|
unsafe { CGRectIntersectsRect(r1, r2) }
|
||||||
|| r1.origin.y + r1.size.height < r2.origin.y
|
|
||||||
|| r1.origin.x > r2.origin.x + r2.size.width
|
|
||||||
|| r1.origin.y > r2.origin.y + r2.size.height);
|
|
||||||
|
|
||||||
overlapping
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Core graphics APIs use flipped coordinate system, while AppKit uses the
|
/// Core graphics APIs use flipped coordinate system, while AppKit uses the
|
||||||
@@ -86,6 +83,23 @@ fn get_ui_element_origin(ui_element: &CFRetained<AXUIElement>) -> Result<CGPoint
|
|||||||
Ok(position_cg_point)
|
Ok(position_cg_point)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send a set origin request to the `ui_element`, return once request is sent.
|
||||||
|
fn set_ui_element_origin_oneshot(
|
||||||
|
ui_element: &CFRetained<AXUIElement>,
|
||||||
|
mut origin: CGPoint,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let ptr_to_origin = NonNull::new((&mut origin as *mut CGPoint).cast::<c_void>()).unwrap();
|
||||||
|
let pos_value = unsafe { AXValue::new(AXValueType::CGPoint, ptr_to_origin) }.unwrap();
|
||||||
|
let pos_attr = CFString::from_static_str("AXPosition");
|
||||||
|
|
||||||
|
let error = unsafe { ui_element.set_attribute_value(&pos_attr, pos_value.deref()) };
|
||||||
|
if error != AXError::Success {
|
||||||
|
return Err(Error::AXError(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Helper function to extract an UI element's size.
|
/// Helper function to extract an UI element's size.
|
||||||
fn get_ui_element_size(ui_element: &CFRetained<AXUIElement>) -> Result<CGSize, Error> {
|
fn get_ui_element_size(ui_element: &CFRetained<AXUIElement>) -> Result<CGSize, Error> {
|
||||||
let mut size_value: *const CFType = std::ptr::null();
|
let mut size_value: *const CFType = std::ptr::null();
|
||||||
@@ -110,6 +124,23 @@ fn get_ui_element_size(ui_element: &CFRetained<AXUIElement>) -> Result<CGSize, E
|
|||||||
Ok(size_cg_size)
|
Ok(size_cg_size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send a set size request to the `ui_element`, return once request is sent.
|
||||||
|
fn set_ui_element_size_oneshot(
|
||||||
|
ui_element: &CFRetained<AXUIElement>,
|
||||||
|
mut size: CGSize,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let ptr_to_size = NonNull::new((&mut size as *mut CGSize).cast::<c_void>()).unwrap();
|
||||||
|
let size_value = unsafe { AXValue::new(AXValueType::CGSize, ptr_to_size) }.unwrap();
|
||||||
|
let size_attr = CFString::from_static_str("AXSize");
|
||||||
|
|
||||||
|
let error = unsafe { ui_element.set_attribute_value(&size_attr, size_value.deref()) };
|
||||||
|
if error != AXError::Success {
|
||||||
|
return Err(Error::AXError(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the frontmost/focused window (as an UI element).
|
/// Get the frontmost/focused window (as an UI element).
|
||||||
fn get_frontmost_window() -> Result<CFRetained<AXUIElement>, Error> {
|
fn get_frontmost_window() -> Result<CFRetained<AXUIElement>, Error> {
|
||||||
let workspace = unsafe { NSWorkspace::sharedWorkspace() };
|
let workspace = unsafe { NSWorkspace::sharedWorkspace() };
|
||||||
@@ -307,6 +338,10 @@ pub(crate) fn move_frontmost_window_to_workspace(space: usize) -> Result<(), Err
|
|||||||
|
|
||||||
let window_frame = get_frontmost_window_frame()?;
|
let window_frame = get_frontmost_window_frame()?;
|
||||||
let close_button_frame = get_frontmost_window_close_button_frame()?;
|
let close_button_frame = get_frontmost_window_close_button_frame()?;
|
||||||
|
let prev_mouse_position = unsafe {
|
||||||
|
let event = CGEvent::new(None);
|
||||||
|
CGEvent::location(event.as_deref())
|
||||||
|
};
|
||||||
|
|
||||||
let mouse_cursor_point = CGPoint::new(
|
let mouse_cursor_point = CGPoint::new(
|
||||||
unsafe { CGRectGetMidX(close_button_frame) },
|
unsafe { CGRectGetMidX(close_button_frame) },
|
||||||
@@ -360,6 +395,9 @@ pub(crate) fn move_frontmost_window_to_workspace(space: usize) -> Result<(), Err
|
|||||||
CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_drag_event.as_deref());
|
CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_drag_event.as_deref());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make a slight delay to make sure the window is grabbed
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
|
||||||
// cast is safe as space is in range [1, 16]
|
// cast is safe as space is in range [1, 16]
|
||||||
let hot_key: c_ushort = 118 + space as c_ushort - 1;
|
let hot_key: c_ushort = 118 + space as c_ushort - 1;
|
||||||
|
|
||||||
@@ -402,9 +440,30 @@ pub(crate) fn move_frontmost_window_to_workspace(space: usize) -> Result<(), Err
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make a slight delay to finish the space transition animation
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Cleanup
|
||||||
|
*/
|
||||||
unsafe {
|
unsafe {
|
||||||
// Let go of the window.
|
// Let go of the window.
|
||||||
CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_up_event.as_deref());
|
CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_up_event.as_deref());
|
||||||
|
|
||||||
|
// Reset mouse position
|
||||||
|
let mouse_reset_event = {
|
||||||
|
CGEvent::new_mouse_event(
|
||||||
|
None,
|
||||||
|
CGEventType::MouseMoved,
|
||||||
|
prev_mouse_position,
|
||||||
|
CGMouseButton::Left,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
CGEvent::set_flags(mouse_reset_event.as_deref(), CGEventFlags(0));
|
||||||
|
CGEvent::post(
|
||||||
|
CGEventTapLocation::HIDEventTap,
|
||||||
|
mouse_reset_event.as_deref(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -461,6 +520,9 @@ fn get_frontmost_window_close_button_frame() -> Result<CGRect, Error> {
|
|||||||
/// 2. For non-main displays, it assumes that they don't have a menu bar, but macOS
|
/// 2. For non-main displays, it assumes that they don't have a menu bar, but macOS
|
||||||
/// puts a menu bar on every display.
|
/// puts a menu bar on every display.
|
||||||
///
|
///
|
||||||
|
/// Update: This could be wrong, but looks like Apple fixed these 2 bugs in macOS
|
||||||
|
/// 26. At least the buggy behaviors disappear in my test.
|
||||||
|
///
|
||||||
///
|
///
|
||||||
/// [^1]: Visible frame: a rectangle defines the portion of the screen in which it
|
/// [^1]: Visible frame: a rectangle defines the portion of the screen in which it
|
||||||
/// is currently safe to draw your app’s content.
|
/// is currently safe to draw your app’s content.
|
||||||
@@ -558,27 +620,61 @@ pub fn move_frontmost_window(x: f64, y: f64) -> Result<(), Error> {
|
|||||||
|
|
||||||
/// Set the frontmost window's frame to the specified frame - adjust size and
|
/// Set the frontmost window's frame to the specified frame - adjust size and
|
||||||
/// location at the same time.
|
/// location at the same time.
|
||||||
|
///
|
||||||
|
/// This function **retries** up to `RETRY` times until the set operations
|
||||||
|
/// successfully get performed.
|
||||||
|
///
|
||||||
|
/// # Retry
|
||||||
|
///
|
||||||
|
/// Retry is added because I encountered a case where `AXUIElementSetAttributeValue()`
|
||||||
|
/// does not work in the expected way. When I execute the `NextDisplay` command
|
||||||
|
/// to move the focused window from a big display (2560x1440) to a small display
|
||||||
|
/// (1440*900), the window size could be set to 1460 sometimes. No idea if this
|
||||||
|
/// is a bug of the Accessibility APIs or due to the improper API uses. So we
|
||||||
|
/// retry for `RETRY` times at most to try our beest make it behave correctly.
|
||||||
pub fn set_frontmost_window_frame(frame: CGRect) -> Result<(), Error> {
|
pub fn set_frontmost_window_frame(frame: CGRect) -> Result<(), Error> {
|
||||||
|
const RETRY: usize = 5;
|
||||||
|
/// Sleep for 50ms as I don't want to send too many requests to the focused
|
||||||
|
/// app and WindowServer because doing that could make them busy and then
|
||||||
|
/// they won't process my set requests.
|
||||||
|
///
|
||||||
|
/// The above is simply my observation, I don't know how the messaging really
|
||||||
|
/// works under the hood.
|
||||||
|
const SLEEP: Duration = Duration::from_millis(50);
|
||||||
|
|
||||||
let frontmost_window = get_frontmost_window()?;
|
let frontmost_window = get_frontmost_window()?;
|
||||||
|
|
||||||
let mut point = frame.origin;
|
/*
|
||||||
let ptr_to_point = NonNull::new((&mut point as *mut CGPoint).cast::<c_void>()).unwrap();
|
* Set window origin
|
||||||
let pos_value = unsafe { AXValue::new(AXValueType::CGPoint, ptr_to_point) }.unwrap();
|
*/
|
||||||
let pos_attr = CFString::from_static_str("AXPosition");
|
set_ui_element_origin_oneshot(&frontmost_window, frame.origin)?;
|
||||||
|
for _ in 0..RETRY {
|
||||||
|
std::thread::sleep(SLEEP);
|
||||||
|
|
||||||
let error = unsafe { frontmost_window.set_attribute_value(&pos_attr, pos_value.deref()) };
|
let current = get_ui_element_origin(&frontmost_window)?;
|
||||||
if error != AXError::Success {
|
if current == frame.origin {
|
||||||
return Err(Error::AXError(error));
|
break;
|
||||||
|
} else {
|
||||||
|
set_ui_element_origin_oneshot(&frontmost_window, frame.origin)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut size = frame.size;
|
/*
|
||||||
let ptr_to_size = NonNull::new((&mut size as *mut CGSize).cast::<c_void>()).unwrap();
|
* Set window size
|
||||||
let size_value = unsafe { AXValue::new(AXValueType::CGSize, ptr_to_size) }.unwrap();
|
*/
|
||||||
let size_attr = CFString::from_static_str("AXSize");
|
set_ui_element_size_oneshot(&frontmost_window, frame.size)?;
|
||||||
|
for _ in 0..RETRY {
|
||||||
|
std::thread::sleep(SLEEP);
|
||||||
|
|
||||||
let error = unsafe { frontmost_window.set_attribute_value(&size_attr, size_value.deref()) };
|
let current = get_ui_element_size(&frontmost_window)?;
|
||||||
if error != AXError::Success {
|
// For size, we do not check if `current` has the exact same value as
|
||||||
return Err(Error::AXError(error));
|
// `frame.size` as I have encountered a case where I ask macOS to set
|
||||||
|
// the height to 1550, but the height gets set to 1551.
|
||||||
|
if cgsize_roughly_equal(current, frame.size, 3.0) {
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
set_ui_element_size_oneshot(&frontmost_window, frame.size)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -624,6 +720,15 @@ pub fn toggle_fullscreen() -> Result<(), Error> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if `lhs` roughly equals to `rhs`. The Roughness can be controlled by
|
||||||
|
/// argument `tolerance`.
|
||||||
|
fn cgsize_roughly_equal(lhs: CGSize, rhs: CGSize, tolerance: f64) -> bool {
|
||||||
|
let width_diff = (lhs.width - rhs.width).abs();
|
||||||
|
let height_diff = (lhs.height - rhs.height).abs();
|
||||||
|
|
||||||
|
width_diff <= tolerance && height_diff <= tolerance
|
||||||
|
}
|
||||||
|
|
||||||
static LAST_FRAME: LazyLock<Mutex<HashMap<CGWindowID, CGRect>>> =
|
static LAST_FRAME: LazyLock<Mutex<HashMap<CGWindowID, CGRect>>> =
|
||||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
@@ -636,3 +741,56 @@ pub(crate) fn get_frontmost_window_last_frame(window_id: CGWindowID) -> Option<C
|
|||||||
let map = LAST_FRAME.lock().unwrap();
|
let map = LAST_FRAME.lock().unwrap();
|
||||||
map.get(&window_id).cloned()
|
map.get(&window_id).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_intersects_adjacent_rects_x() {
|
||||||
|
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
|
||||||
|
let r2 = CGRect::new(CGPoint::new(100.0, 0.0), CGSize::new(100.0, 100.0));
|
||||||
|
assert!(
|
||||||
|
!intersects(r1, r2),
|
||||||
|
"Adjacent rects on X should not intersect"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_intersects_adjacent_rects_y() {
|
||||||
|
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
|
||||||
|
let r2 = CGRect::new(CGPoint::new(0.0, 100.0), CGSize::new(100.0, 100.0));
|
||||||
|
assert!(
|
||||||
|
!intersects(r1, r2),
|
||||||
|
"Adjacent rects on Y should not intersect"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_intersects_overlapping_rects() {
|
||||||
|
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
|
||||||
|
let r2 = CGRect::new(CGPoint::new(50.0, 50.0), CGSize::new(100.0, 100.0));
|
||||||
|
assert!(intersects(r1, r2), "Overlapping rects should intersect");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_intersects_separate_rects() {
|
||||||
|
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
|
||||||
|
let r2 = CGRect::new(CGPoint::new(101.0, 101.0), CGSize::new(100.0, 100.0));
|
||||||
|
assert!(!intersects(r1, r2), "Separate rects should not intersect");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_intersects_contained_rect() {
|
||||||
|
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
|
||||||
|
let r2 = CGRect::new(CGPoint::new(10.0, 10.0), CGSize::new(50.0, 50.0));
|
||||||
|
assert!(intersects(r1, r2), "Contained rect should intersect");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_intersects_identical_rects() {
|
||||||
|
let r1 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
|
||||||
|
let r2 = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(100.0, 100.0));
|
||||||
|
assert!(intersects(r1, r2), "Identical rects should intersect");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
|||||||
use tauri_plugin_global_shortcut::ShortcutState;
|
use tauri_plugin_global_shortcut::ShortcutState;
|
||||||
|
|
||||||
pub(crate) const EXTENSION_ID: &str = "Window Management";
|
pub(crate) const EXTENSION_ID: &str = "Window Management";
|
||||||
|
pub(crate) const EXTENSION_NAME_LOWERCASE: &str = "window management";
|
||||||
|
|
||||||
/// JSON file for this extension.
|
/// JSON file for this extension.
|
||||||
pub(crate) const PLUGIN_JSON_FILE: &str = include_str!("./plugin.json");
|
pub(crate) const PLUGIN_JSON_FILE: &str = include_str!("./plugin.json");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use super::EXTENSION_ID;
|
use super::EXTENSION_ID;
|
||||||
|
use super::EXTENSION_NAME_LOWERCASE;
|
||||||
use crate::common::document::{DataSourceReference, Document};
|
use crate::common::document::{DataSourceReference, Document};
|
||||||
use crate::common::{
|
use crate::common::{
|
||||||
error::SearchError,
|
error::SearchError,
|
||||||
@@ -81,6 +82,16 @@ impl SearchSource for WindowManagementSearchSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An "extension" type extension should return all its
|
||||||
|
// sub-extensions when the query string matches its name.
|
||||||
|
// To do this, we score the extension name and take that
|
||||||
|
// into account.
|
||||||
|
if let Some(main_extension_score) =
|
||||||
|
calculate_text_similarity(&query_string_lowercase, &EXTENSION_NAME_LOWERCASE)
|
||||||
|
{
|
||||||
|
score += main_extension_score;
|
||||||
|
}
|
||||||
|
|
||||||
score
|
score
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
pub(crate) mod api;
|
pub(crate) mod api;
|
||||||
pub(crate) mod built_in;
|
pub(crate) mod built_in;
|
||||||
pub(crate) mod third_party;
|
pub(crate) mod third_party;
|
||||||
|
pub(crate) mod view_extension;
|
||||||
|
|
||||||
use crate::common::document::ExtensionOnOpened;
|
use crate::common::document::ExtensionOnOpened;
|
||||||
use crate::common::document::ExtensionOnOpenedType;
|
use crate::common::document::ExtensionOnOpenedType;
|
||||||
use crate::common::document::OnOpened;
|
use crate::common::document::OnOpened;
|
||||||
use crate::common::register::SearchSourceRegistry;
|
use crate::common::register::SearchSourceRegistry;
|
||||||
use crate::util::platform::Platform;
|
use crate::util::platform::Platform;
|
||||||
|
use crate::util::version::COCO_VERSION;
|
||||||
|
use crate::util::version::parse_coco_semver;
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use bitflags::bitflags;
|
use bitflags::bitflags;
|
||||||
use borrowme::{Borrow, ToOwned};
|
use borrowme::{Borrow, ToOwned};
|
||||||
use derive_more::Display;
|
use derive_more::Display;
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
|
use semver::Version as SemVer;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::Value as Json;
|
use serde_json::Value as Json;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
use std::ops::Deref;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
use third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
use third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||||
@@ -24,6 +29,7 @@ use third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
|||||||
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
|
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
|
||||||
const PLUGIN_JSON_FILE_NAME: &str = "plugin.json";
|
const PLUGIN_JSON_FILE_NAME: &str = "plugin.json";
|
||||||
const ASSETS_DIRECTORY_FILE_NAME: &str = "assets";
|
const ASSETS_DIRECTORY_FILE_NAME: &str = "assets";
|
||||||
|
const PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION: &str = "minimum_coco_version";
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
@@ -39,10 +45,9 @@ pub struct Extension {
|
|||||||
name: String,
|
name: String,
|
||||||
/// ID of the developer.
|
/// ID of the developer.
|
||||||
///
|
///
|
||||||
/// * For built-in extensions, this will always be None.
|
/// * For built-in extensions, this is None.
|
||||||
/// * For third-party first-layer extensions, the on-disk plugin.json file
|
/// * For third-party main extensions, this field contains the extension developer ID.
|
||||||
/// won't contain this field, but we will set this field for them after reading them into the memory.
|
/// * For third-party sub extensions, this field is be None.
|
||||||
/// * For third-party sub extensions, this field will be None.
|
|
||||||
developer: Option<String>,
|
developer: Option<String>,
|
||||||
/// Platforms supported by this extension.
|
/// Platforms supported by this extension.
|
||||||
///
|
///
|
||||||
@@ -110,11 +115,26 @@ pub struct Extension {
|
|||||||
|
|
||||||
/// For View extensions, path to the HTML file/page that coco will load
|
/// For View extensions, path to the HTML file/page that coco will load
|
||||||
/// and render. Otherwise, `None`.
|
/// and render. Otherwise, `None`.
|
||||||
|
///
|
||||||
|
/// It could be a path relative to the extension root directory, Coco will
|
||||||
|
/// canonicalize it in that case.
|
||||||
page: Option<String>,
|
page: Option<String>,
|
||||||
|
|
||||||
|
ui: Option<ViewExtensionUISettings>,
|
||||||
|
|
||||||
/// Permission that this extension requires.
|
/// Permission that this extension requires.
|
||||||
permission: Option<ExtensionPermission>,
|
permission: Option<ExtensionPermission>,
|
||||||
|
|
||||||
|
/// The version of Coco app that this extension requires.
|
||||||
|
///
|
||||||
|
/// If not set, then this extension is compatible with all versions of Coco app.
|
||||||
|
///
|
||||||
|
/// It is only for third-party extensions. Built-in extensions should always
|
||||||
|
/// set this field to `None`.
|
||||||
|
#[serde(deserialize_with = "deserialize_coco_semver")]
|
||||||
|
#[serde(default)] // None if this field is missing
|
||||||
|
minimum_coco_version: Option<SemVer>,
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* The following fields are currently useless to us but are needed by our
|
* The following fields are currently useless to us but are needed by our
|
||||||
* extension store.
|
* extension store.
|
||||||
@@ -126,6 +146,17 @@ pub struct Extension {
|
|||||||
version: Option<Json>,
|
version: Option<Json>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Settings that control the built-in UI Components
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||||
|
pub(crate) struct ViewExtensionUISettings {
|
||||||
|
/// Show the search bar
|
||||||
|
search_bar: bool,
|
||||||
|
/// Show the filter bar
|
||||||
|
filter_bar: bool,
|
||||||
|
/// Show the footer
|
||||||
|
footer: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Bundle ID uniquely identifies an extension.
|
/// Bundle ID uniquely identifies an extension.
|
||||||
#[derive(Debug, Deserialize, Serialize, PartialEq, Clone)]
|
#[derive(Debug, Deserialize, Serialize, PartialEq, Clone)]
|
||||||
pub(crate) struct ExtensionBundleId {
|
pub(crate) struct ExtensionBundleId {
|
||||||
@@ -262,11 +293,19 @@ impl Extension {
|
|||||||
ExtensionType::Script => todo!("not supported yet"),
|
ExtensionType::Script => todo!("not supported yet"),
|
||||||
ExtensionType::Setting => todo!("not supported yet"),
|
ExtensionType::Setting => todo!("not supported yet"),
|
||||||
ExtensionType::View => {
|
ExtensionType::View => {
|
||||||
|
let name = self.name.clone();
|
||||||
|
let icon = self.icon.clone();
|
||||||
let page = self.page.as_ref().unwrap_or_else(|| {
|
let page = self.page.as_ref().unwrap_or_else(|| {
|
||||||
panic!("View extension [{}]'s [page] field is not set, something wrong with your extension validity check", self.id);
|
panic!("View extension [{}]'s [page] field is not set, something wrong with your extension validity check", self.id);
|
||||||
}).clone();
|
}).clone();
|
||||||
|
let ui = self.ui.clone();
|
||||||
|
|
||||||
let extension_on_opened_type = ExtensionOnOpenedType::View { page };
|
let extension_on_opened_type = ExtensionOnOpenedType::View {
|
||||||
|
name,
|
||||||
|
icon,
|
||||||
|
page,
|
||||||
|
ui,
|
||||||
|
};
|
||||||
let extension_on_opened = ExtensionOnOpened {
|
let extension_on_opened = ExtensionOnOpened {
|
||||||
ty: extension_on_opened_type,
|
ty: extension_on_opened_type,
|
||||||
settings,
|
settings,
|
||||||
@@ -276,6 +315,9 @@ impl Extension {
|
|||||||
|
|
||||||
Some(on_opened)
|
Some(on_opened)
|
||||||
}
|
}
|
||||||
|
ExtensionType::Unknown => {
|
||||||
|
unreachable!("Extensions of type [Unknown] should never be opened")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,6 +392,26 @@ impl Extension {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deserialize Coco SemVer from a string.
|
||||||
|
///
|
||||||
|
/// This function adapts `parse_coco_semver` to work with serde's `deserialize_with`
|
||||||
|
/// attribute.
|
||||||
|
fn deserialize_coco_semver<'de, D>(deserializer: D) -> Result<Option<SemVer>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let version_str: Option<String> = Option::deserialize(deserializer)?;
|
||||||
|
let Some(version_str) = version_str else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(semver) = parse_coco_semver(&version_str) else {
|
||||||
|
return Err(serde::de::Error::custom("version string format is invalid"));
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(semver))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||||
pub(crate) struct CommandAction {
|
pub(crate) struct CommandAction {
|
||||||
pub(crate) exec: String,
|
pub(crate) exec: String,
|
||||||
@@ -416,7 +478,7 @@ impl QuicklinkLink {
|
|||||||
/// if any.
|
/// if any.
|
||||||
pub(crate) fn concatenate_url(
|
pub(crate) fn concatenate_url(
|
||||||
&self,
|
&self,
|
||||||
user_supplied_args: &Option<HashMap<String, String>>,
|
user_supplied_args: &Option<HashMap<String, Json>>,
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
for component in self.components.iter() {
|
for component in self.components.iter() {
|
||||||
@@ -428,20 +490,23 @@ impl QuicklinkLink {
|
|||||||
argument_name,
|
argument_name,
|
||||||
default,
|
default,
|
||||||
} => {
|
} => {
|
||||||
let opt_argument_value = {
|
let opt_argument_value: Option<&str> = {
|
||||||
let user_supplied_arg = user_supplied_args
|
let user_supplied_arg = user_supplied_args
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|map| map.get(argument_name.as_str()));
|
.and_then(|map| map.get(argument_name.as_str()));
|
||||||
|
|
||||||
if user_supplied_arg.is_some() {
|
if user_supplied_arg.is_some() {
|
||||||
user_supplied_arg
|
user_supplied_arg.map(|json| {
|
||||||
|
json.as_str()
|
||||||
|
.expect("quicklink should provide string arguments")
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
default.as_ref()
|
default.as_deref()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let argument_value_str = match opt_argument_value {
|
let argument_value_str = match opt_argument_value {
|
||||||
Some(str) => str.as_str(),
|
Some(str) => str,
|
||||||
// None => an empty string
|
// None => an empty string
|
||||||
None => "",
|
None => "",
|
||||||
};
|
};
|
||||||
@@ -550,6 +615,10 @@ pub enum ExtensionType {
|
|||||||
AiExtension,
|
AiExtension,
|
||||||
#[display("View")]
|
#[display("View")]
|
||||||
View,
|
View,
|
||||||
|
/// Add this variant for better compatibility: Future versions of Coco may
|
||||||
|
/// add new extension types that older versions of Coco are not aware of.
|
||||||
|
#[display("Unknown")]
|
||||||
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExtensionType {
|
impl ExtensionType {
|
||||||
@@ -797,6 +866,22 @@ pub(crate) async fn init_extensions(tauri_app_handle: &AppHandle) -> Result<(),
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Is `extension` compatible with the current running Coco app?
|
||||||
|
///
|
||||||
|
/// It is defined as a tauri command rather than an associated function because
|
||||||
|
/// it will be used in frontend code as well.
|
||||||
|
///
|
||||||
|
/// Async tauri commands are required to return `Result<T, E>`, this function
|
||||||
|
/// only needs to return a boolean, so it is not marked async.
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn is_extension_compatible(extension: Extension) -> bool {
|
||||||
|
let Some(ref minimum_coco_version) = extension.minimum_coco_version else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
COCO_VERSION.deref() >= minimum_coco_version
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) async fn enable_extension(
|
pub(crate) async fn enable_extension(
|
||||||
tauri_app_handle: AppHandle,
|
tauri_app_handle: AppHandle,
|
||||||
@@ -904,6 +989,13 @@ pub(crate) fn canonicalize_relative_icon_path(
|
|||||||
let icon_path = Path::new(icon_str);
|
let icon_path = Path::new(icon_str);
|
||||||
|
|
||||||
if icon_path.is_relative() {
|
if icon_path.is_relative() {
|
||||||
|
// If we enter this if statement, then there are 2 possible cases:
|
||||||
|
//
|
||||||
|
// 1. icon_path is a font class code, e.g., "font_coco"
|
||||||
|
// 2. icon_path is a indeed a relative path
|
||||||
|
//
|
||||||
|
// We distinguish between these 2 cases by checking if `absolute_icon_path` exists
|
||||||
|
|
||||||
let absolute_icon_path = {
|
let absolute_icon_path = {
|
||||||
let mut assets_directory = extension_dir.join(ASSETS_DIRECTORY_FILE_NAME);
|
let mut assets_directory = extension_dir.join(ASSETS_DIRECTORY_FILE_NAME);
|
||||||
assets_directory.push(icon_path);
|
assets_directory.push(icon_path);
|
||||||
@@ -963,6 +1055,14 @@ pub(crate) fn canonicalize_relative_page_path(
|
|||||||
.page
|
.page
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("this should be invoked on a View extension");
|
.expect("this should be invoked on a View extension");
|
||||||
|
|
||||||
|
// Skip HTTP links
|
||||||
|
if let Ok(url) = url::Url::parse(page)
|
||||||
|
&& ["http", "https"].contains(&url.scheme())
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let page_path = Path::new(page);
|
let page_path = Path::new(page);
|
||||||
|
|
||||||
if page_path.is_relative() {
|
if page_path.is_relative() {
|
||||||
@@ -1741,7 +1841,7 @@ mod tests {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
let mut user_args = HashMap::new();
|
let mut user_args = HashMap::new();
|
||||||
user_args.insert("other_param".to_string(), "value".to_string());
|
user_args.insert("other_param".to_string(), Json::String("value".to_string()));
|
||||||
let result = link.concatenate_url(&Some(user_args));
|
let result = link.concatenate_url(&Some(user_args));
|
||||||
assert_eq!(result, "https://www.google.com/search?q=");
|
assert_eq!(result, "https://www.google.com/search?q=");
|
||||||
}
|
}
|
||||||
@@ -1778,7 +1878,7 @@ mod tests {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
let mut user_args = HashMap::new();
|
let mut user_args = HashMap::new();
|
||||||
user_args.insert("other_param".to_string(), "value".to_string());
|
user_args.insert("other_param".to_string(), Json::String("value".to_string()));
|
||||||
let result = link.concatenate_url(&Some(user_args));
|
let result = link.concatenate_url(&Some(user_args));
|
||||||
assert_eq!(result, "https://www.google.com/search?q=rust");
|
assert_eq!(result, "https://www.google.com/search?q=rust");
|
||||||
}
|
}
|
||||||
@@ -1801,7 +1901,7 @@ mod tests {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
let mut user_args = HashMap::new();
|
let mut user_args = HashMap::new();
|
||||||
user_args.insert("query".to_string(), "python".to_string());
|
user_args.insert("query".to_string(), Json::String("python".to_string()));
|
||||||
let result = link.concatenate_url(&Some(user_args));
|
let result = link.concatenate_url(&Some(user_args));
|
||||||
assert_eq!(result, "https://www.google.com/search?q=python");
|
assert_eq!(result, "https://www.google.com/search?q=python");
|
||||||
}
|
}
|
||||||
|
|||||||
33
src-tauri/src/extension/third_party/check.rs
vendored
33
src-tauri/src/extension/third_party/check.rs
vendored
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
use crate::extension::Extension;
|
use crate::extension::Extension;
|
||||||
use crate::extension::ExtensionType;
|
use crate::extension::ExtensionType;
|
||||||
|
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
|
||||||
use crate::util::platform::Platform;
|
use crate::util::platform::Platform;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
@@ -179,6 +180,13 @@ fn check_sub_extension_only(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sub_extension.minimum_coco_version.is_some() {
|
||||||
|
return Err(format!(
|
||||||
|
"invalid sub-extension [{}-{}]: [{}] cannot be set for sub-extensions",
|
||||||
|
extension_id, sub_extension.id, PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +239,14 @@ fn check_main_extension_or_sub_extension(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If field `ui` is Some, then it should be a View
|
||||||
|
if extension.ui.is_some() && extension.r#type != ExtensionType::View {
|
||||||
|
return Err(format!(
|
||||||
|
"invalid {}, field [ui] is set for a non-View extension",
|
||||||
|
identifier
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,8 +283,10 @@ mod tests {
|
|||||||
hotkey: None,
|
hotkey: None,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
page,
|
page,
|
||||||
|
ui: None,
|
||||||
permission: None,
|
permission: None,
|
||||||
settings: None,
|
settings: None,
|
||||||
|
minimum_coco_version: None,
|
||||||
screenshots: None,
|
screenshots: None,
|
||||||
url: None,
|
url: None,
|
||||||
version: None,
|
version: None,
|
||||||
@@ -532,6 +550,21 @@ mod tests {
|
|||||||
"fields [commands/scripts/quicklinks/views] should not be set in sub-extensions"
|
"fields [commands/scripts/quicklinks/views] should not be set in sub-extensions"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sub_extension_cannot_set_minimum_coco_version() {
|
||||||
|
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
|
||||||
|
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
|
||||||
|
sub_cmd.minimum_coco_version = Some(semver::Version::new(0, 8, 0));
|
||||||
|
extension.commands = Some(vec![sub_cmd]);
|
||||||
|
|
||||||
|
let result = general_check(&extension);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains(&format!(
|
||||||
|
"[{}] cannot be set for sub-extensions",
|
||||||
|
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
|
||||||
|
)));
|
||||||
|
}
|
||||||
/* Test check_sub_extension_only */
|
/* Test check_sub_extension_only */
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
use super::check_compatibility_via_mcv;
|
||||||
|
use crate::extension::PLUGIN_JSON_FILE_NAME;
|
||||||
use crate::extension::third_party::check::general_check;
|
use crate::extension::third_party::check::general_check;
|
||||||
use crate::extension::third_party::install::{
|
use crate::extension::third_party::install::{
|
||||||
convert_page, filter_out_incompatible_sub_extensions, is_extension_installed,
|
filter_out_incompatible_sub_extensions, is_extension_installed,
|
||||||
};
|
};
|
||||||
use crate::extension::third_party::{
|
use crate::extension::third_party::{
|
||||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE, get_third_party_extension_directory,
|
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE, get_third_party_extension_directory,
|
||||||
@@ -8,7 +10,6 @@ use crate::extension::third_party::{
|
|||||||
use crate::extension::{
|
use crate::extension::{
|
||||||
Extension, canonicalize_relative_icon_path, canonicalize_relative_page_path,
|
Extension, canonicalize_relative_icon_path, canonicalize_relative_page_path,
|
||||||
};
|
};
|
||||||
use crate::extension::{ExtensionType, PLUGIN_JSON_FILE_NAME};
|
|
||||||
use crate::util::platform::Platform;
|
use crate::util::platform::Platform;
|
||||||
use serde_json::Value as Json;
|
use serde_json::Value as Json;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -79,6 +80,10 @@ pub(crate) async fn install_local_extension(
|
|||||||
let mut extension_json: Json =
|
let mut extension_json: Json =
|
||||||
serde_json::from_str(&plugin_json_content).map_err(|e| e.to_string())?;
|
serde_json::from_str(&plugin_json_content).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if !check_compatibility_via_mcv(&extension_json)? {
|
||||||
|
return Err("app_incompatible".into());
|
||||||
|
}
|
||||||
|
|
||||||
// Set the main extension ID to the directory name
|
// Set the main extension ID to the directory name
|
||||||
let extension_obj = extension_json
|
let extension_obj = extension_json
|
||||||
.as_object_mut()
|
.as_object_mut()
|
||||||
@@ -158,7 +163,7 @@ pub(crate) async fn install_local_extension(
|
|||||||
//
|
//
|
||||||
// This is definitely error-prone, but we have to do this until we have
|
// This is definitely error-prone, but we have to do this until we have
|
||||||
// structured error type
|
// structured error type
|
||||||
return Err("incompatible".into());
|
return Err("platform_incompatible".into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* Check ends here */
|
/* Check ends here */
|
||||||
@@ -221,53 +226,6 @@ pub(crate) async fn install_local_extension(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
/*
|
|
||||||
* Call convert_page() to update the page files. This has to be done after
|
|
||||||
* writing the extension files
|
|
||||||
*/
|
|
||||||
let absolute_page_paths: Vec<PathBuf> = {
|
|
||||||
fn canonicalize_page_path(page_path: &Path, extension_root: &Path) -> PathBuf {
|
|
||||||
if page_path.is_relative() {
|
|
||||||
// It is relative to the extension root directory
|
|
||||||
extension_root.join(page_path)
|
|
||||||
} else {
|
|
||||||
page_path.into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if extension.r#type == ExtensionType::View {
|
|
||||||
let page = extension
|
|
||||||
.page
|
|
||||||
.as_ref()
|
|
||||||
.expect("View extension should set its page field");
|
|
||||||
let path = canonicalize_page_path(Path::new(page.as_str()), &dest_dir);
|
|
||||||
|
|
||||||
vec![path]
|
|
||||||
} else if extension.r#type.contains_sub_items()
|
|
||||||
&& let Some(ref views) = extension.views
|
|
||||||
{
|
|
||||||
let mut paths = Vec::with_capacity(views.len());
|
|
||||||
|
|
||||||
for view in views.iter() {
|
|
||||||
let page = view
|
|
||||||
.page
|
|
||||||
.as_ref()
|
|
||||||
.expect("View extension should set its page field");
|
|
||||||
let path = canonicalize_page_path(Path::new(page.as_str()), &dest_dir);
|
|
||||||
|
|
||||||
paths.push(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
paths
|
|
||||||
} else {
|
|
||||||
// No pages in this extension
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
for page_path in absolute_page_paths {
|
|
||||||
convert_page(&page_path).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Canonicalize relative icon and page paths
|
// Canonicalize relative icon and page paths
|
||||||
canonicalize_relative_icon_path(&dest_dir, &mut extension)?;
|
canonicalize_relative_icon_path(&dest_dir, &mut extension)?;
|
||||||
canonicalize_relative_page_path(&dest_dir, &mut extension)?;
|
canonicalize_relative_page_path(&dest_dir, &mut extension)?;
|
||||||
|
|||||||
409
src-tauri/src/extension/third_party/install/mod.rs
vendored
409
src-tauri/src/extension/third_party/install/mod.rs
vendored
@@ -4,19 +4,27 @@
|
|||||||
//! # How
|
//! # How
|
||||||
//!
|
//!
|
||||||
//! Technically, installing an extension involves the following steps. The order
|
//! Technically, installing an extension involves the following steps. The order
|
||||||
//! may vary between implementations.
|
//! varies between 2 implementations.
|
||||||
//!
|
//!
|
||||||
//! 1. Check if it is already installed, if so, return
|
//! 1. Check if it is already installed, if so, return
|
||||||
//!
|
//!
|
||||||
//! 2. Correct the `plugin.json` JSON if it does not conform to our `struct
|
//! 2. Check if it is compatible by inspecting the "minimum_coco_version"
|
||||||
|
//! field. If it is incompatible, reject and error out.
|
||||||
|
//!
|
||||||
|
//! This should be done before convert `plugin.json` JSON to `struct Extension`
|
||||||
|
//! as the definition of `struct Extension` could change in the future, in this
|
||||||
|
//! case, we want to tell users that "it is an incompatible extension" rather
|
||||||
|
//! than "this extension is invalid".
|
||||||
|
//!
|
||||||
|
//! 3. Correct the `plugin.json` JSON if it does not conform to our `struct
|
||||||
//! Extension` definition. This can happen because the JSON written by
|
//! Extension` definition. This can happen because the JSON written by
|
||||||
//! developers is in a simplified form for a better developer experience.
|
//! developers is in a simplified form for a better developer experience.
|
||||||
//!
|
//!
|
||||||
//! 3. Validate the corrected `plugin.json`
|
//! 4. Validate the corrected `plugin.json`
|
||||||
//! 1. misc checks
|
//! 1. misc checks
|
||||||
//! 2. Platform compatibility check
|
//! 2. Platform compatibility check
|
||||||
//!
|
//!
|
||||||
//! 4. Write the extension files to the corresponding location
|
//! 5. Write the extension files to the corresponding location
|
||||||
//!
|
//!
|
||||||
//! * developer directory
|
//! * developer directory
|
||||||
//! * extension directory
|
//! * extension directory
|
||||||
@@ -25,11 +33,6 @@
|
|||||||
//! * plugin.json file
|
//! * plugin.json file
|
||||||
//! * View pages if exist
|
//! * View pages if exist
|
||||||
//!
|
//!
|
||||||
//! 5. If this extension contains any View extensions, call `convert_page()`
|
|
||||||
//! on them to make them loadable by Tauri/webview.
|
|
||||||
//!
|
|
||||||
//! See `convert_page()` for more info.
|
|
||||||
//!
|
|
||||||
//! 6. Canonicalize `Extension.icon` and `Extension.page` fields if they are
|
//! 6. Canonicalize `Extension.icon` and `Extension.page` fields if they are
|
||||||
//! relative paths
|
//! relative paths
|
||||||
//!
|
//!
|
||||||
@@ -42,8 +45,11 @@ pub(crate) mod local_extension;
|
|||||||
pub(crate) mod store;
|
pub(crate) mod store;
|
||||||
|
|
||||||
use crate::extension::Extension;
|
use crate::extension::Extension;
|
||||||
|
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
|
||||||
use crate::util::platform::Platform;
|
use crate::util::platform::Platform;
|
||||||
use std::path::Path;
|
use crate::util::version::{COCO_VERSION, parse_coco_semver};
|
||||||
|
use serde_json::Value as Json;
|
||||||
|
use std::ops::Deref;
|
||||||
|
|
||||||
use super::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
use super::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||||
|
|
||||||
@@ -115,117 +121,31 @@ pub(crate) fn filter_out_incompatible_sub_extensions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert the page file to make it loadable by the Tauri/Webview.
|
/// Inspect the "minimum_coco_version" field and see if this extension is
|
||||||
pub(crate) async fn convert_page(absolute_page_path: &Path) -> Result<(), String> {
|
/// compatible with the current Coco app.
|
||||||
assert!(absolute_page_path.is_absolute());
|
fn check_compatibility_via_mcv(plugin_json: &Json) -> Result<bool, String> {
|
||||||
|
let Some(mcv_json) = plugin_json.get(PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION) else {
|
||||||
let page_content = tokio::fs::read_to_string(absolute_page_path)
|
return Ok(true);
|
||||||
.await
|
};
|
||||||
.map_err(|e| e.to_string())?;
|
if mcv_json == &Json::Null {
|
||||||
|
return Ok(true);
|
||||||
let new_page_content = _convert_page(&page_content, absolute_page_path)?;
|
|
||||||
|
|
||||||
// overwrite it
|
|
||||||
tokio::fs::write(absolute_page_path, new_page_content)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// NOTE: There is no Rust implementation of `convertFileSrc()` in Tauri. Our
|
|
||||||
/// impl here is based on [comment](https://github.com/tauri-apps/tauri/issues/12022#issuecomment-2572879115)
|
|
||||||
fn convert_file_src(path: &Path) -> Result<String, String> {
|
|
||||||
#[cfg(any(windows, target_os = "android"))]
|
|
||||||
let base = "http://asset.localhost/";
|
|
||||||
#[cfg(not(any(windows, target_os = "android")))]
|
|
||||||
let base = "asset://localhost/";
|
|
||||||
|
|
||||||
let path =
|
|
||||||
dunce::canonicalize(path).map_err(|e| format!("Failed to canonicalize path: {}", e))?;
|
|
||||||
let path_str = path.to_string_lossy();
|
|
||||||
let encoded = urlencoding::encode(&path_str);
|
|
||||||
|
|
||||||
Ok(format!("{base}{encoded}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tauri cannot directly access the file system, to make a file loadable, we
|
|
||||||
/// have to `canonicalize()` and `convertFileSrc()` its path before passing it
|
|
||||||
/// to Tauri.
|
|
||||||
///
|
|
||||||
/// View extension's page is a HTML file that Coco (Tauri) will load, we need
|
|
||||||
/// to process all `<PATH>` tags:
|
|
||||||
///
|
|
||||||
/// 1. `<script type="xxx" crossorigin src="<PATH>"></script>`
|
|
||||||
/// 2. `<a href="<PATH>">xxx</a>`
|
|
||||||
/// 3. `<link rel="xxx" href="<PATH>"/>`
|
|
||||||
/// 4. `<img class="xxx" src="<PATH>" alt="xxx"/>`
|
|
||||||
fn _convert_page(page_content: &str, absolute_page_path: &Path) -> Result<String, String> {
|
|
||||||
use scraper::{Html, Selector};
|
|
||||||
|
|
||||||
/// Helper function.
|
|
||||||
///
|
|
||||||
/// Search `document` for the tag attributes specified by `tag_with_attribute`
|
|
||||||
/// and `tag_attribute`, call `convert_file_src()`, then update the attribute
|
|
||||||
/// value with the function return value.
|
|
||||||
fn modify_tag_attributes(
|
|
||||||
document: &Html,
|
|
||||||
modified_html: &mut String,
|
|
||||||
base_dir: &Path,
|
|
||||||
tag_with_attribute: &str,
|
|
||||||
tag_attribute: &str,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let script_selector = Selector::parse(tag_with_attribute).unwrap();
|
|
||||||
for element in document.select(&script_selector) {
|
|
||||||
if let Some(src) = element.value().attr(tag_attribute) {
|
|
||||||
if !src.starts_with("http://")
|
|
||||||
&& !src.starts_with("https://")
|
|
||||||
&& !src.starts_with("asset://")
|
|
||||||
&& !src.starts_with("http://asset.localhost/")
|
|
||||||
{
|
|
||||||
// It could be a path like "/assets/index-41be3ec9.js", but it
|
|
||||||
// is still a relative path. We need to remove the starting /
|
|
||||||
// or path.join() will think it is an absolute path and does nothing
|
|
||||||
let corrected_src = if src.starts_with('/') { &src[1..] } else { src };
|
|
||||||
|
|
||||||
let full_path = base_dir.join(corrected_src);
|
|
||||||
|
|
||||||
let converted_path = convert_file_src(full_path.as_path())?;
|
|
||||||
*modified_html = modified_html.replace(
|
|
||||||
&format!("{}=\"{}\"", tag_attribute, src),
|
|
||||||
&format!("{}=\"{}\"", tag_attribute, converted_path),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let base_dir = absolute_page_path
|
let Some(mcv_str) = mcv_json.as_str() else {
|
||||||
.parent()
|
return Err(format!(
|
||||||
.ok_or_else(|| format!("page path is invalid, it should have a parent path"))?;
|
"invalid extension: field [{}] should be a string",
|
||||||
let document: Html = Html::parse_document(page_content);
|
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
|
||||||
let mut modified_html: String = page_content.to_string();
|
));
|
||||||
|
};
|
||||||
|
|
||||||
modify_tag_attributes(
|
let Some(mcv) = parse_coco_semver(mcv_str) else {
|
||||||
&document,
|
return Err(format!(
|
||||||
&mut modified_html,
|
"invalid extension: [{}] is not a valid version string",
|
||||||
base_dir,
|
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
|
||||||
"script[src]",
|
));
|
||||||
"src",
|
};
|
||||||
)?;
|
|
||||||
modify_tag_attributes(&document, &mut modified_html, base_dir, "a[href]", "href")?;
|
|
||||||
modify_tag_attributes(
|
|
||||||
&document,
|
|
||||||
&mut modified_html,
|
|
||||||
base_dir,
|
|
||||||
"link[href]",
|
|
||||||
"href",
|
|
||||||
)?;
|
|
||||||
modify_tag_attributes(&document, &mut modified_html, base_dir, "img[src]", "src")?;
|
|
||||||
|
|
||||||
Ok(modified_html)
|
Ok(COCO_VERSION.deref() >= &mcv)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -259,6 +179,8 @@ mod tests {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
settings: None,
|
settings: None,
|
||||||
page: None,
|
page: None,
|
||||||
|
ui: None,
|
||||||
|
minimum_coco_version: None,
|
||||||
permission: None,
|
permission: None,
|
||||||
screenshots: None,
|
screenshots: None,
|
||||||
url: None,
|
url: None,
|
||||||
@@ -428,257 +350,4 @@ mod tests {
|
|||||||
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Macos);
|
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Macos);
|
||||||
assert_eq!(main_extension.views.unwrap().len(), 1);
|
assert_eq!(main_extension.views.unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_convert_page_script_tag() {
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
let temp_dir = TempDir::new().unwrap();
|
|
||||||
let html_file = temp_dir.path().join("test.html");
|
|
||||||
let js_file = temp_dir.path().join("main.js");
|
|
||||||
|
|
||||||
let html_content = r#"<html><body><script src="main.js"></script></body></html>"#;
|
|
||||||
std::fs::write(&html_file, html_content).unwrap();
|
|
||||||
std::fs::write(&js_file, "").unwrap();
|
|
||||||
|
|
||||||
let result = _convert_page(html_content, &html_file).unwrap();
|
|
||||||
|
|
||||||
let path = convert_file_src(&js_file).unwrap();
|
|
||||||
let expected = format!(
|
|
||||||
"<html><body><script src=\"{}\"></script></body></html>",
|
|
||||||
path
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(result, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_convert_page_script_tag_with_a_root_char() {
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
let temp_dir = TempDir::new().unwrap();
|
|
||||||
let html_file = temp_dir.path().join("test.html");
|
|
||||||
let js_file = temp_dir.path().join("main.js");
|
|
||||||
|
|
||||||
let html_content = r#"<html><body><script src="/main.js"></script></body></html>"#;
|
|
||||||
std::fs::write(&html_file, html_content).unwrap();
|
|
||||||
std::fs::write(&js_file, "").unwrap();
|
|
||||||
|
|
||||||
let result = _convert_page(html_content, &html_file).unwrap();
|
|
||||||
|
|
||||||
let path = convert_file_src(&js_file).unwrap();
|
|
||||||
let expected = format!(
|
|
||||||
"<html><body><script src=\"{}\"></script></body></html>",
|
|
||||||
path
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(result, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_convert_page_a_tag() {
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
let temp_dir = TempDir::new().unwrap();
|
|
||||||
let html_file = temp_dir.path().join("test.html");
|
|
||||||
let js_file = temp_dir.path().join("main.js");
|
|
||||||
|
|
||||||
let html_content = r#"<html><body><a href="main.js">foo</a></body></html>"#;
|
|
||||||
std::fs::write(&html_file, html_content).unwrap();
|
|
||||||
std::fs::write(&js_file, "").unwrap();
|
|
||||||
|
|
||||||
let result = _convert_page(html_content, &html_file).unwrap();
|
|
||||||
|
|
||||||
let path = convert_file_src(&js_file).unwrap();
|
|
||||||
let expected = format!("<html><body><a href=\"{}\">foo</a></body></html>", path);
|
|
||||||
|
|
||||||
assert_eq!(result, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_convert_page_a_tag_with_a_root_char() {
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
let temp_dir = TempDir::new().unwrap();
|
|
||||||
let html_file = temp_dir.path().join("test.html");
|
|
||||||
let js_file = temp_dir.path().join("main.js");
|
|
||||||
|
|
||||||
let html_content = r#"<html><body><a href="/main.js">foo</a></body></html>"#;
|
|
||||||
std::fs::write(&html_file, html_content).unwrap();
|
|
||||||
std::fs::write(&js_file, "").unwrap();
|
|
||||||
|
|
||||||
let result = _convert_page(html_content, &html_file).unwrap();
|
|
||||||
|
|
||||||
let path = convert_file_src(&js_file).unwrap();
|
|
||||||
let expected = format!("<html><body><a href=\"{}\">foo</a></body></html>", path);
|
|
||||||
|
|
||||||
assert_eq!(result, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_convert_page_link_href_tag() {
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
let temp_dir = TempDir::new().unwrap();
|
|
||||||
let html_file = temp_dir.path().join("test.html");
|
|
||||||
let css_file = temp_dir.path().join("main.css");
|
|
||||||
|
|
||||||
let html_content = r#"<html><body><link rel="stylesheet" href="main.css"/></body></html>"#;
|
|
||||||
std::fs::write(&html_file, html_content).unwrap();
|
|
||||||
std::fs::write(&css_file, "").unwrap();
|
|
||||||
|
|
||||||
let result = _convert_page(html_content, &html_file).unwrap();
|
|
||||||
|
|
||||||
let path = convert_file_src(&css_file).unwrap();
|
|
||||||
let expected = format!(
|
|
||||||
"<html><body><link rel=\"stylesheet\" href=\"{}\"/></body></html>",
|
|
||||||
path
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(result, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_convert_page_link_href_tag_with_a_root_tag() {
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
let temp_dir = TempDir::new().unwrap();
|
|
||||||
let html_file = temp_dir.path().join("test.html");
|
|
||||||
let css_file = temp_dir.path().join("main.css");
|
|
||||||
|
|
||||||
let html_content = r#"<html><body><link rel="stylesheet" href="/main.css"/></body></html>"#;
|
|
||||||
std::fs::write(&html_file, html_content).unwrap();
|
|
||||||
std::fs::write(&css_file, "").unwrap();
|
|
||||||
|
|
||||||
let result = _convert_page(html_content, &html_file).unwrap();
|
|
||||||
|
|
||||||
let path = convert_file_src(&css_file).unwrap();
|
|
||||||
let expected = format!(
|
|
||||||
"<html><body><link rel=\"stylesheet\" href=\"{}\"/></body></html>",
|
|
||||||
path
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(result, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_convert_page_img_src_tag() {
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
let temp_dir = TempDir::new().unwrap();
|
|
||||||
let html_file = temp_dir.path().join("test.html");
|
|
||||||
let png_file = temp_dir.path().join("main.png");
|
|
||||||
|
|
||||||
let html_content =
|
|
||||||
r#"<html><body> <img class="fit-picture" src="main.png" alt="xxx" /></body></html>"#;
|
|
||||||
std::fs::write(&html_file, html_content).unwrap();
|
|
||||||
std::fs::write(&png_file, "").unwrap();
|
|
||||||
|
|
||||||
let result = _convert_page(html_content, &html_file).unwrap();
|
|
||||||
|
|
||||||
let path = convert_file_src(&png_file).unwrap();
|
|
||||||
let expected = format!(
|
|
||||||
"<html><body> <img class=\"fit-picture\" src=\"{}\" alt=\"xxx\" /></body></html>",
|
|
||||||
path
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(result, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_convert_page_img_src_tag_with_a_root_tag() {
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
let temp_dir = TempDir::new().unwrap();
|
|
||||||
let html_file = temp_dir.path().join("test.html");
|
|
||||||
let png_file = temp_dir.path().join("main.png");
|
|
||||||
|
|
||||||
let html_content =
|
|
||||||
r#"<html><body> <img class="fit-picture" src="/main.png" alt="xxx" /></body></html>"#;
|
|
||||||
std::fs::write(&html_file, html_content).unwrap();
|
|
||||||
std::fs::write(&png_file, "").unwrap();
|
|
||||||
|
|
||||||
let result = _convert_page(html_content, &html_file).unwrap();
|
|
||||||
|
|
||||||
let path = convert_file_src(&png_file).unwrap();
|
|
||||||
let expected = format!(
|
|
||||||
"<html><body> <img class=\"fit-picture\" src=\"{}\" alt=\"xxx\" /></body></html>",
|
|
||||||
path
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(result, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_convert_page_contain_both_script_and_a_tags() {
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
let temp_dir = TempDir::new().unwrap();
|
|
||||||
let html_file = temp_dir.path().join("test.html");
|
|
||||||
let js_file = temp_dir.path().join("main.js");
|
|
||||||
|
|
||||||
let html_content =
|
|
||||||
r#"<html><body><a href="main.js">foo</a><script src="main.js"></script></body></html>"#;
|
|
||||||
std::fs::write(&html_file, html_content).unwrap();
|
|
||||||
std::fs::write(&js_file, "").unwrap();
|
|
||||||
|
|
||||||
let result = _convert_page(html_content, &html_file).unwrap();
|
|
||||||
|
|
||||||
let path = convert_file_src(&js_file).unwrap();
|
|
||||||
let expected = format!(
|
|
||||||
"<html><body><a href=\"{}\">foo</a><script src=\"{}\"></script></body></html>",
|
|
||||||
path, path
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(result, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_convert_page_contain_both_script_and_a_tags_with_root_char() {
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
let temp_dir = TempDir::new().unwrap();
|
|
||||||
let html_file = temp_dir.path().join("test.html");
|
|
||||||
let js_file = temp_dir.path().join("main.js");
|
|
||||||
|
|
||||||
let html_content = r#"<html><body><a href="/main.js">foo</a><script src="/main.js"></script></body></html>"#;
|
|
||||||
std::fs::write(&html_file, html_content).unwrap();
|
|
||||||
std::fs::write(&js_file, "").unwrap();
|
|
||||||
|
|
||||||
let result = _convert_page(html_content, &html_file).unwrap();
|
|
||||||
|
|
||||||
let path = convert_file_src(&js_file).unwrap();
|
|
||||||
let expected = format!(
|
|
||||||
"<html><body><a href=\"{}\">foo</a><script src=\"{}\"></script></body></html>",
|
|
||||||
path, path
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(result, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_convert_page_empty_html() {
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
let temp_dir = TempDir::new().unwrap();
|
|
||||||
let html_file = temp_dir.path().join("test.html");
|
|
||||||
|
|
||||||
let html_content = "";
|
|
||||||
std::fs::write(&html_file, html_content).unwrap();
|
|
||||||
let result = _convert_page(html_content, &html_file).unwrap();
|
|
||||||
assert!(result.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_convert_page_only_html_tag() {
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
let temp_dir = TempDir::new().unwrap();
|
|
||||||
let html_file = temp_dir.path().join("test.html");
|
|
||||||
|
|
||||||
let html_content = "<html></html>";
|
|
||||||
std::fs::write(&html_file, html_content).unwrap();
|
|
||||||
let result = _convert_page(html_content, &html_file).unwrap();
|
|
||||||
assert_eq!(result, html_content);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Extension store related stuff.
|
//! Extension store related stuff.
|
||||||
|
|
||||||
use super::super::LOCAL_QUERY_SOURCE_TYPE;
|
use super::super::LOCAL_QUERY_SOURCE_TYPE;
|
||||||
|
use super::check_compatibility_via_mcv;
|
||||||
use super::is_extension_installed;
|
use super::is_extension_installed;
|
||||||
use crate::common::document::DataSourceReference;
|
use crate::common::document::DataSourceReference;
|
||||||
use crate::common::document::Document;
|
use crate::common::document::Document;
|
||||||
@@ -10,14 +11,12 @@ use crate::common::search::QuerySource;
|
|||||||
use crate::common::search::SearchQuery;
|
use crate::common::search::SearchQuery;
|
||||||
use crate::common::traits::SearchSource;
|
use crate::common::traits::SearchSource;
|
||||||
use crate::extension::Extension;
|
use crate::extension::Extension;
|
||||||
use crate::extension::ExtensionType;
|
|
||||||
use crate::extension::PLUGIN_JSON_FILE_NAME;
|
use crate::extension::PLUGIN_JSON_FILE_NAME;
|
||||||
use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||||
use crate::extension::canonicalize_relative_icon_path;
|
use crate::extension::canonicalize_relative_icon_path;
|
||||||
use crate::extension::canonicalize_relative_page_path;
|
use crate::extension::canonicalize_relative_page_path;
|
||||||
use crate::extension::third_party::check::general_check;
|
use crate::extension::third_party::check::general_check;
|
||||||
use crate::extension::third_party::get_third_party_extension_directory;
|
use crate::extension::third_party::get_third_party_extension_directory;
|
||||||
use crate::extension::third_party::install::convert_page;
|
|
||||||
use crate::extension::third_party::install::filter_out_incompatible_sub_extensions;
|
use crate::extension::third_party::install::filter_out_incompatible_sub_extensions;
|
||||||
use crate::server::http_client::HttpClient;
|
use crate::server::http_client::HttpClient;
|
||||||
use crate::util::platform::Platform;
|
use crate::util::platform::Platform;
|
||||||
@@ -26,8 +25,6 @@ use reqwest::StatusCode;
|
|||||||
use serde_json::Map as JsonObject;
|
use serde_json::Map as JsonObject;
|
||||||
use serde_json::Value as Json;
|
use serde_json::Value as Json;
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use std::path::Path;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
|
|
||||||
const DATA_SOURCE_ID: &str = "Extension Store";
|
const DATA_SOURCE_ID: &str = "Extension Store";
|
||||||
@@ -107,15 +104,23 @@ pub(crate) async fn search_extension(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to send request: {:?}", e))?;
|
.map_err(|e| format!("Failed to send request: {:?}", e))?;
|
||||||
|
|
||||||
|
if response.status() == StatusCode::NOT_FOUND {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
// The response of a ES style search request
|
// The response of a ES style search request
|
||||||
let mut response: JsonObject<String, Json> = response
|
let mut response: JsonObject<String, Json> = response
|
||||||
.json()
|
.json()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to parse response: {:?}", e))?;
|
.map_err(|e| format!("Failed to parse response: {:?}", e))?;
|
||||||
|
|
||||||
let hits_json = response
|
let hits_json = response.remove("hits").unwrap_or_else(|| {
|
||||||
.remove("hits")
|
panic!(
|
||||||
.expect("the JSON response should contain field [hits]");
|
"the JSON response should contain field [hits], response [{:?}]",
|
||||||
|
response
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
let mut hits = match hits_json {
|
let mut hits = match hits_json {
|
||||||
Json::Object(obj) => obj,
|
Json::Object(obj) => obj,
|
||||||
_ => panic!(
|
_ => panic!(
|
||||||
@@ -262,6 +267,10 @@ pub(crate) async fn install_extension_from_store(
|
|||||||
let mut extension: Json = serde_json::from_str(&plugin_json_content)
|
let mut extension: Json = serde_json::from_str(&plugin_json_content)
|
||||||
.map_err(|e| format!("Failed to parse plugin.json: {}", e))?;
|
.map_err(|e| format!("Failed to parse plugin.json: {}", e))?;
|
||||||
|
|
||||||
|
if !check_compatibility_via_mcv(&extension)? {
|
||||||
|
return Err("app_incompatible".into());
|
||||||
|
}
|
||||||
|
|
||||||
let mut_ref_to_developer_object: &mut Json = extension
|
let mut_ref_to_developer_object: &mut Json = extension
|
||||||
.as_object_mut()
|
.as_object_mut()
|
||||||
.expect("plugin.json should be an object")
|
.expect("plugin.json should be an object")
|
||||||
@@ -311,7 +320,7 @@ pub(crate) async fn install_extension_from_store(
|
|||||||
let current_platform = Platform::current();
|
let current_platform = Platform::current();
|
||||||
if let Some(ref platforms) = extension.platforms {
|
if let Some(ref platforms) = extension.platforms {
|
||||||
if !platforms.contains(¤t_platform) {
|
if !platforms.contains(¤t_platform) {
|
||||||
return Err("this extension is not compatible with your OS".into());
|
return Err("platform_incompatible".into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,53 +408,6 @@ pub(crate) async fn install_extension_from_store(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
/*
|
|
||||||
* Call convert_page() to update the page files. This has to be done after
|
|
||||||
* writing the extension files
|
|
||||||
*/
|
|
||||||
let absolute_page_paths: Vec<PathBuf> = {
|
|
||||||
fn canonicalize_page_path(page_path: &Path, extension_root: &Path) -> PathBuf {
|
|
||||||
if page_path.is_relative() {
|
|
||||||
// It is relative to the extension root directory
|
|
||||||
extension_root.join(page_path)
|
|
||||||
} else {
|
|
||||||
page_path.into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if extension.r#type == ExtensionType::View {
|
|
||||||
let page = extension
|
|
||||||
.page
|
|
||||||
.as_ref()
|
|
||||||
.expect("View extension should set its page field");
|
|
||||||
let path = canonicalize_page_path(Path::new(page.as_str()), &extension_directory);
|
|
||||||
|
|
||||||
vec![path]
|
|
||||||
} else if extension.r#type.contains_sub_items()
|
|
||||||
&& let Some(ref views) = extension.views
|
|
||||||
{
|
|
||||||
let mut paths = Vec::with_capacity(views.len());
|
|
||||||
|
|
||||||
for view in views.iter() {
|
|
||||||
let page = view
|
|
||||||
.page
|
|
||||||
.as_ref()
|
|
||||||
.expect("View extension should set its page field");
|
|
||||||
let path = canonicalize_page_path(Path::new(page.as_str()), &extension_directory);
|
|
||||||
|
|
||||||
paths.push(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
paths
|
|
||||||
} else {
|
|
||||||
// No pages in this extension
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
for page_path in absolute_page_paths {
|
|
||||||
convert_page(&page_path).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Canonicalize relative icon and page paths
|
// Canonicalize relative icon and page paths
|
||||||
canonicalize_relative_icon_path(&extension_directory, &mut extension)?;
|
canonicalize_relative_icon_path(&extension_directory, &mut extension)?;
|
||||||
canonicalize_relative_page_path(&extension_directory, &mut extension)?;
|
canonicalize_relative_page_path(&extension_directory, &mut extension)?;
|
||||||
|
|||||||
227
src-tauri/src/extension/third_party/mod.rs
vendored
227
src-tauri/src/extension/third_party/mod.rs
vendored
@@ -15,14 +15,22 @@ use crate::common::search::QuerySource;
|
|||||||
use crate::common::search::SearchQuery;
|
use crate::common::search::SearchQuery;
|
||||||
use crate::common::traits::SearchSource;
|
use crate::common::traits::SearchSource;
|
||||||
use crate::extension::ExtensionBundleIdBorrowed;
|
use crate::extension::ExtensionBundleIdBorrowed;
|
||||||
|
use crate::extension::ExtensionType;
|
||||||
|
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
|
||||||
use crate::extension::calculate_text_similarity;
|
use crate::extension::calculate_text_similarity;
|
||||||
use crate::extension::canonicalize_relative_page_path;
|
use crate::extension::canonicalize_relative_page_path;
|
||||||
|
use crate::extension::is_extension_compatible;
|
||||||
use crate::util::platform::Platform;
|
use crate::util::platform::Platform;
|
||||||
|
use crate::util::version::COCO_VERSION;
|
||||||
|
use crate::util::version::parse_coco_semver;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use borrowme::ToOwned;
|
use borrowme::ToOwned;
|
||||||
use check::general_check;
|
use check::general_check;
|
||||||
use function_name::named;
|
use function_name::named;
|
||||||
|
use semver::Version as SemVer;
|
||||||
|
use serde_json::Value as Json;
|
||||||
use std::io::ErrorKind;
|
use std::io::ErrorKind;
|
||||||
|
use std::ops::Deref;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -122,6 +130,154 @@ pub(crate) async fn load_third_party_extensions_from_directory(
|
|||||||
let plugin_json_file_content = tokio::fs::read_to_string(&plugin_json_file_path)
|
let plugin_json_file_content = tokio::fs::read_to_string(&plugin_json_file_path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let plugin_json = match serde_json::from_str::<Json>(&plugin_json_file_content) {
|
||||||
|
Ok(json) => json,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"invalid extension: [{}]: file [{}] is not a JSON, error: '{}'",
|
||||||
|
extension_dir_file_name,
|
||||||
|
plugin_json_file_path.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
continue 'extension;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let opt_mcv: Option<SemVer> = {
|
||||||
|
match plugin_json.get(PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION) {
|
||||||
|
None => None,
|
||||||
|
// NULL is considered None as well.
|
||||||
|
Some(Json::Null) => None,
|
||||||
|
|
||||||
|
Some(mcv_json) => {
|
||||||
|
let Some(mcv_str) = mcv_json.as_str() else {
|
||||||
|
log::warn!(
|
||||||
|
"invalid extension: [{}]: field [{}] is not a string",
|
||||||
|
extension_dir_file_name,
|
||||||
|
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
|
||||||
|
);
|
||||||
|
continue 'extension;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(mcv) = parse_coco_semver(mcv_str) else {
|
||||||
|
log::warn!(
|
||||||
|
"invalid extension: [{}]: field [{}] has invalid version string",
|
||||||
|
extension_dir_file_name,
|
||||||
|
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
|
||||||
|
);
|
||||||
|
continue 'extension;
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(mcv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_compatible: bool = match opt_mcv {
|
||||||
|
Some(ref mcv) => COCO_VERSION.deref() >= mcv,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !is_compatible {
|
||||||
|
/*
|
||||||
|
* Extract only these field: [id, name, icon, type] from the JSON,
|
||||||
|
* then return a minimal Extension instance with these fields set:
|
||||||
|
*
|
||||||
|
* - `id` and `developer`: to make it identifiable
|
||||||
|
* - `name`, `icon` and `type`: to display it in the Extensions page
|
||||||
|
* - `minimum_coco_version`: so that we can check compatibility using it
|
||||||
|
*/
|
||||||
|
let Some(id) = plugin_json.get("id").and_then(|v| v.as_str()) else {
|
||||||
|
log::warn!(
|
||||||
|
"invalid extension: [{}]: field [id] is missing or not a string",
|
||||||
|
extension_dir_file_name,
|
||||||
|
);
|
||||||
|
continue 'extension;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(name) = plugin_json.get("name").and_then(|v| v.as_str()) else {
|
||||||
|
log::warn!(
|
||||||
|
"invalid extension: [{}]: field [name] is missing or not a string",
|
||||||
|
extension_dir_file_name,
|
||||||
|
);
|
||||||
|
continue 'extension;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(icon) = plugin_json.get("icon").and_then(|v| v.as_str()) else {
|
||||||
|
log::warn!(
|
||||||
|
"invalid extension: [{}]: field [icon] is missing or not a string",
|
||||||
|
extension_dir_file_name,
|
||||||
|
);
|
||||||
|
continue 'extension;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(extension_type_str) = plugin_json.get("type").and_then(|v| v.as_str())
|
||||||
|
else {
|
||||||
|
log::warn!(
|
||||||
|
"invalid extension: [{}]: field [type] is missing or not a string",
|
||||||
|
extension_dir_file_name,
|
||||||
|
);
|
||||||
|
continue 'extension;
|
||||||
|
};
|
||||||
|
|
||||||
|
let extension_type: ExtensionType = match serde_plain::from_str(extension_type_str)
|
||||||
|
{
|
||||||
|
Ok(t) => t,
|
||||||
|
// Future Coco may have new Extension types that the we don't know
|
||||||
|
//
|
||||||
|
// This should be the only place where `ExtensionType::Unknown`
|
||||||
|
// could be constructed.
|
||||||
|
Err(_e) => ExtensionType::Unknown,
|
||||||
|
};
|
||||||
|
|
||||||
|
// We don't extract the developer ID from the plugin.json to rely
|
||||||
|
// less on it.
|
||||||
|
let developer = developer_dir
|
||||||
|
.file_name()
|
||||||
|
.into_string()
|
||||||
|
.expect("developer ID should be UTF-8 encoded");
|
||||||
|
|
||||||
|
let mut incompatible_extension = Extension {
|
||||||
|
id: id.to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
icon: icon.to_string(),
|
||||||
|
r#type: extension_type,
|
||||||
|
developer: Some(developer),
|
||||||
|
description: String::new(),
|
||||||
|
enabled: false,
|
||||||
|
platforms: None,
|
||||||
|
action: None,
|
||||||
|
quicklink: None,
|
||||||
|
commands: None,
|
||||||
|
scripts: None,
|
||||||
|
quicklinks: None,
|
||||||
|
views: None,
|
||||||
|
alias: None,
|
||||||
|
hotkey: None,
|
||||||
|
settings: None,
|
||||||
|
page: None,
|
||||||
|
ui: None,
|
||||||
|
permission: None,
|
||||||
|
minimum_coco_version: opt_mcv,
|
||||||
|
screenshots: None,
|
||||||
|
url: None,
|
||||||
|
version: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Turn icon path into an absolute path if it is a valid relative path
|
||||||
|
canonicalize_relative_icon_path(
|
||||||
|
&extension_dir.path(),
|
||||||
|
&mut incompatible_extension,
|
||||||
|
)?;
|
||||||
|
// No need to canonicalize the path field as it is not set
|
||||||
|
|
||||||
|
extensions.push(incompatible_extension);
|
||||||
|
continue 'extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This is a compatible extension.
|
||||||
|
*/
|
||||||
let mut extension = match serde_json::from_str::<Extension>(&plugin_json_file_content) {
|
let mut extension = match serde_json::from_str::<Extension>(&plugin_json_file_content) {
|
||||||
Ok(extension) => extension,
|
Ok(extension) => extension,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -246,7 +402,6 @@ impl ThirdPartyExtensionsSearchSource {
|
|||||||
if extension.supports_alias_hotkey() {
|
if extension.supports_alias_hotkey() {
|
||||||
if let Some(ref hotkey) = extension.hotkey {
|
if let Some(ref hotkey) = extension.hotkey {
|
||||||
let on_opened = extension.on_opened().unwrap_or_else(|| panic!( "extension has hotkey, but on_open() returns None, extension ID [{}], extension type [{:?}]", extension.id, extension.r#type));
|
let on_opened = extension.on_opened().unwrap_or_else(|| panic!( "extension has hotkey, but on_open() returns None, extension ID [{}], extension type [{:?}]", extension.id, extension.r#type));
|
||||||
|
|
||||||
let extension_id_clone = extension.id.clone();
|
let extension_id_clone = extension.id.clone();
|
||||||
|
|
||||||
tauri_app_handle
|
tauri_app_handle
|
||||||
@@ -755,13 +910,28 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
|||||||
let extensions_read_lock =
|
let extensions_read_lock =
|
||||||
futures::executor::block_on(async { inner_clone.extensions.read().await });
|
futures::executor::block_on(async { inner_clone.extensions.read().await });
|
||||||
|
|
||||||
for extension in extensions_read_lock.iter().filter(|ext| ext.enabled) {
|
for extension in extensions_read_lock
|
||||||
|
.iter()
|
||||||
|
// field minimum_coco_extension is only set for main extensions.
|
||||||
|
.filter(|ext| ext.enabled && is_extension_compatible(Extension::clone(ext)))
|
||||||
|
{
|
||||||
if extension.r#type.contains_sub_items() {
|
if extension.r#type.contains_sub_items() {
|
||||||
|
let opt_main_extension_lowercase_name =
|
||||||
|
if extension.r#type == ExtensionType::Extension {
|
||||||
|
Some(extension.name.to_lowercase())
|
||||||
|
} else {
|
||||||
|
// None if it is of type `ExtensionType::Group`
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(ref commands) = extension.commands {
|
if let Some(ref commands) = extension.commands {
|
||||||
for command in commands.iter().filter(|cmd| cmd.enabled) {
|
for command in commands.iter().filter(|cmd| cmd.enabled) {
|
||||||
if let Some(hit) =
|
if let Some(hit) = extension_to_hit(
|
||||||
extension_to_hit(command, &query_lower, opt_data_source.as_deref())
|
command,
|
||||||
{
|
&query_lower,
|
||||||
|
opt_data_source.as_deref(),
|
||||||
|
opt_main_extension_lowercase_name.as_deref(),
|
||||||
|
) {
|
||||||
hits.push(hit);
|
hits.push(hit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -769,9 +939,12 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
|||||||
|
|
||||||
if let Some(ref scripts) = extension.scripts {
|
if let Some(ref scripts) = extension.scripts {
|
||||||
for script in scripts.iter().filter(|script| script.enabled) {
|
for script in scripts.iter().filter(|script| script.enabled) {
|
||||||
if let Some(hit) =
|
if let Some(hit) = extension_to_hit(
|
||||||
extension_to_hit(script, &query_lower, opt_data_source.as_deref())
|
script,
|
||||||
{
|
&query_lower,
|
||||||
|
opt_data_source.as_deref(),
|
||||||
|
opt_main_extension_lowercase_name.as_deref(),
|
||||||
|
) {
|
||||||
hits.push(hit);
|
hits.push(hit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -783,6 +956,7 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
|||||||
quicklink,
|
quicklink,
|
||||||
&query_lower,
|
&query_lower,
|
||||||
opt_data_source.as_deref(),
|
opt_data_source.as_deref(),
|
||||||
|
opt_main_extension_lowercase_name.as_deref(),
|
||||||
) {
|
) {
|
||||||
hits.push(hit);
|
hits.push(hit);
|
||||||
}
|
}
|
||||||
@@ -790,17 +964,20 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref views) = extension.views {
|
if let Some(ref views) = extension.views {
|
||||||
for view in views.iter().filter(|link| link.enabled) {
|
for view in views.iter().filter(|view| view.enabled) {
|
||||||
if let Some(hit) =
|
if let Some(hit) = extension_to_hit(
|
||||||
extension_to_hit(view, &query_lower, opt_data_source.as_deref())
|
view,
|
||||||
{
|
&query_lower,
|
||||||
|
opt_data_source.as_deref(),
|
||||||
|
opt_main_extension_lowercase_name.as_deref(),
|
||||||
|
) {
|
||||||
hits.push(hit);
|
hits.push(hit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if let Some(hit) =
|
if let Some(hit) =
|
||||||
extension_to_hit(extension, &query_lower, opt_data_source.as_deref())
|
extension_to_hit(extension, &query_lower, opt_data_source.as_deref(), None)
|
||||||
{
|
{
|
||||||
hits.push(hit);
|
hits.push(hit);
|
||||||
}
|
}
|
||||||
@@ -839,10 +1016,18 @@ pub(crate) async fn uninstall_extension(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Argument `opt_main_extension_lowercase_name`: If `extension` is a sub-extension
|
||||||
|
/// of an `extension` type extension, then this argument contains the lowercase
|
||||||
|
/// name of that extension. Otherwise, None.
|
||||||
|
///
|
||||||
|
/// This argument is needed as an "extension" type extension should return all its
|
||||||
|
/// sub-extensions when the query string matches its name. To do this, we pass the
|
||||||
|
/// extension name, score it and take that into account.
|
||||||
pub(crate) fn extension_to_hit(
|
pub(crate) fn extension_to_hit(
|
||||||
extension: &Extension,
|
extension: &Extension,
|
||||||
query_lower: &str,
|
query_lower: &str,
|
||||||
opt_data_source: Option<&str>,
|
opt_data_source: Option<&str>,
|
||||||
|
opt_main_extension_lowercase_name: Option<&str>,
|
||||||
) -> Option<(Document, f64)> {
|
) -> Option<(Document, f64)> {
|
||||||
if !extension.searchable() {
|
if !extension.searchable() {
|
||||||
return None;
|
return None;
|
||||||
@@ -865,14 +1050,26 @@ pub(crate) fn extension_to_hit(
|
|||||||
if let Some(title_score) =
|
if let Some(title_score) =
|
||||||
calculate_text_similarity(&query_lower, &extension.name.to_lowercase())
|
calculate_text_similarity(&query_lower, &extension.name.to_lowercase())
|
||||||
{
|
{
|
||||||
total_score += title_score * 1.0; // Weight for title
|
total_score += title_score;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score based on alias match if available
|
// Score based on alias match if available
|
||||||
// Alias is considered less important than title, so it gets a lower weight.
|
// Alias is considered less important than title, so it gets a lower weight.
|
||||||
if let Some(alias) = &extension.alias {
|
if let Some(alias) = &extension.alias {
|
||||||
if let Some(alias_score) = calculate_text_similarity(&query_lower, &alias.to_lowercase()) {
|
if let Some(alias_score) = calculate_text_similarity(&query_lower, &alias.to_lowercase()) {
|
||||||
total_score += alias_score * 0.7; // Weight for alias
|
total_score += alias_score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An "extension" type extension should return all its
|
||||||
|
// sub-extensions when the query string matches its ID.
|
||||||
|
// To do this, we score the extension ID and take that
|
||||||
|
// into account.
|
||||||
|
if let Some(main_extension_lowercase_id) = opt_main_extension_lowercase_name {
|
||||||
|
if let Some(main_extension_score) =
|
||||||
|
calculate_text_similarity(&query_lower, main_extension_lowercase_id)
|
||||||
|
{
|
||||||
|
total_score += main_extension_score;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
38
src-tauri/src/extension/view_extension.rs
Normal file
38
src-tauri/src/extension/view_extension.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
//! View extension-related stuff
|
||||||
|
|
||||||
|
use actix_files::Files;
|
||||||
|
use actix_web::{App, HttpServer, dev::ServerHandle};
|
||||||
|
use std::path::Path;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
static FILE_SERVER_HANDLE: Mutex<Option<ServerHandle>> = Mutex::const_new(None);
|
||||||
|
|
||||||
|
/// Start a static HTTP file server serving the directory specified by `path`.
|
||||||
|
/// Return the URL of the server.
|
||||||
|
pub(crate) async fn serve_files_in(path: &Path) -> String {
|
||||||
|
const ADDR: &str = "127.0.0.1";
|
||||||
|
|
||||||
|
let mut guard = FILE_SERVER_HANDLE.lock().await;
|
||||||
|
if let Some(prev_server_handle) = guard.take() {
|
||||||
|
prev_server_handle.stop(true).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = path.to_path_buf();
|
||||||
|
let http_server =
|
||||||
|
HttpServer::new(move || App::new().service(Files::new("/", &path).show_files_listing()))
|
||||||
|
// Set port to 0 and let OS assign a port to us
|
||||||
|
.bind((ADDR, 0))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let assigned_port = http_server.addrs()[0].port();
|
||||||
|
|
||||||
|
let server = http_server.disable_signals().workers(1).run();
|
||||||
|
|
||||||
|
let new_handle = server.handle();
|
||||||
|
|
||||||
|
tokio::spawn(server);
|
||||||
|
|
||||||
|
*guard = Some(new_handle);
|
||||||
|
|
||||||
|
format!("http://{}:{}", ADDR, assigned_port)
|
||||||
|
}
|
||||||
@@ -7,22 +7,26 @@ mod server;
|
|||||||
mod settings;
|
mod settings;
|
||||||
mod setup;
|
mod setup;
|
||||||
mod shortcut;
|
mod shortcut;
|
||||||
mod util;
|
// We need this in main.rs, so it has to be pub
|
||||||
|
pub mod util;
|
||||||
|
|
||||||
use crate::common::register::SearchSourceRegistry;
|
use crate::common::register::SearchSourceRegistry;
|
||||||
use crate::common::{CHECK_WINDOW_LABEL, MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL};
|
use crate::common::{CHECK_WINDOW_LABEL, MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL};
|
||||||
use crate::server::servers::{load_or_insert_default_server, load_servers_token};
|
use crate::server::servers::{load_or_insert_default_server, load_servers_token};
|
||||||
|
use crate::util::logging::set_up_tauri_logger;
|
||||||
use crate::util::prevent_default;
|
use crate::util::prevent_default;
|
||||||
use autostart::change_autostart;
|
use autostart::change_autostart;
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
use tauri::plugin::TauriPlugin;
|
use tauri::{
|
||||||
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, WebviewWindow, WindowEvent};
|
AppHandle, Emitter, LogicalPosition, Manager, PhysicalPosition, WebviewWindow, WindowEvent,
|
||||||
|
};
|
||||||
use tauri_plugin_autostart::MacosLauncher;
|
use tauri_plugin_autostart::MacosLauncher;
|
||||||
|
|
||||||
/// Tauri store name
|
/// Tauri store name
|
||||||
pub(crate) const COCO_TAURI_STORE: &str = "coco_tauri_store";
|
pub(crate) const COCO_TAURI_STORE: &str = "coco_tauri_store";
|
||||||
|
pub(crate) const WINDOW_CENTER_BASELINE_HEIGHT: i32 = 590;
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
static ref PREVIOUS_MONITOR_NAME: Mutex<Option<String>> = Mutex::new(None);
|
static ref PREVIOUS_MONITOR_NAME: Mutex<Option<String>> = Mutex::new(None);
|
||||||
@@ -44,6 +48,26 @@ async fn change_window_height(handle: AppHandle, height: u32) {
|
|||||||
let mut size = window.outer_size().unwrap();
|
let mut size = window.outer_size().unwrap();
|
||||||
size.height = height;
|
size.height = height;
|
||||||
window.set_size(size).unwrap();
|
window.set_size(size).unwrap();
|
||||||
|
|
||||||
|
// Center the window horizontally and vertically based on the baseline height of 590
|
||||||
|
let monitor = window.primary_monitor().ok().flatten().or_else(|| {
|
||||||
|
window
|
||||||
|
.available_monitors()
|
||||||
|
.ok()
|
||||||
|
.and_then(|ms| ms.into_iter().next())
|
||||||
|
});
|
||||||
|
if let Some(monitor) = monitor {
|
||||||
|
let monitor_position = monitor.position();
|
||||||
|
let monitor_size = monitor.size();
|
||||||
|
|
||||||
|
let window_width = window.outer_size().unwrap().width as i32;
|
||||||
|
let x = monitor_position.x + (monitor_size.width as i32 - window_width) / 2;
|
||||||
|
|
||||||
|
let y =
|
||||||
|
monitor_position.y + (monitor_size.height as i32 - WINDOW_CENTER_BASELINE_HEIGHT) / 2;
|
||||||
|
|
||||||
|
let _ = window.set_position(PhysicalPosition::new(x, y));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
@@ -89,7 +113,7 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.plugin(
|
.plugin(
|
||||||
tauri_plugin_updater::Builder::new()
|
tauri_plugin_updater::Builder::new()
|
||||||
.default_version_comparator(crate::util::updater::custom_version_comparator)
|
.default_version_comparator(crate::util::version::custom_version_comparator)
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
.plugin(tauri_plugin_windows_version::init())
|
.plugin(tauri_plugin_windows_version::init())
|
||||||
@@ -166,10 +190,13 @@ pub fn run() {
|
|||||||
extension::third_party::install::store::install_extension_from_store,
|
extension::third_party::install::store::install_extension_from_store,
|
||||||
extension::third_party::install::local_extension::install_local_extension,
|
extension::third_party::install::local_extension::install_local_extension,
|
||||||
extension::third_party::uninstall_extension,
|
extension::third_party::uninstall_extension,
|
||||||
|
extension::is_extension_compatible,
|
||||||
extension::api::apis,
|
extension::api::apis,
|
||||||
extension::api::fs::read_dir,
|
extension::api::fs::read_dir,
|
||||||
settings::set_allow_self_signature,
|
settings::set_allow_self_signature,
|
||||||
settings::get_allow_self_signature,
|
settings::get_allow_self_signature,
|
||||||
|
settings::set_local_query_source_weight,
|
||||||
|
settings::get_local_query_source_weight,
|
||||||
assistant::ask_ai,
|
assistant::ask_ai,
|
||||||
crate::common::document::open,
|
crate::common::document::open,
|
||||||
extension::built_in::file_search::config::get_file_system_config,
|
extension::built_in::file_search::config::get_file_system_config,
|
||||||
@@ -179,6 +206,7 @@ pub fn run() {
|
|||||||
setup::backend_setup,
|
setup::backend_setup,
|
||||||
util::app_lang::update_app_lang,
|
util::app_lang::update_app_lang,
|
||||||
util::path::path_absolute,
|
util::path::path_absolute,
|
||||||
|
util::logging::app_log_dir
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -265,127 +293,112 @@ async fn show_coco(app_handle: AppHandle) {
|
|||||||
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
|
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||||
move_window_to_active_monitor(&window);
|
move_window_to_active_monitor(&window);
|
||||||
|
|
||||||
let _ = window.show();
|
cfg_if::cfg_if! {
|
||||||
let _ = window.unminimize();
|
if #[cfg(target_os = "macos")] {
|
||||||
|
use tauri_nspanel::ManagerExt;
|
||||||
|
|
||||||
// The Window Management (WM) extension (macOS-only) controls the
|
let app_handle_clone = app_handle.clone();
|
||||||
// frontmost window. Setting focus on macOS makes Coco the frontmost
|
|
||||||
// window, which means the WM extension would control Coco instead of other
|
app_handle.run_on_main_thread(move || {
|
||||||
// windows, which is not what we want.
|
let panel = app_handle_clone.get_webview_panel(MAIN_WINDOW_LABEL).unwrap();
|
||||||
//
|
|
||||||
// On Linux/Windows, however, setting focus is a necessity to ensure that
|
panel.show_and_make_key();
|
||||||
// users open Coco's window, then they can start typing, without needing
|
}).unwrap();
|
||||||
// to click on the window.
|
} else {
|
||||||
#[cfg(not(target_os = "macos"))]
|
let _ = window.show();
|
||||||
let _ = window.set_focus();
|
let _ = window.unminimize();
|
||||||
|
// The Window Management (WM) extension (macOS-only) controls the
|
||||||
|
// frontmost window. Setting focus on macOS makes Coco the frontmost
|
||||||
|
// window, which means the WM extension would control Coco instead of other
|
||||||
|
// windows, which is not what we want.
|
||||||
|
//
|
||||||
|
// On Linux/Windows, however, setting focus is a necessity to ensure that
|
||||||
|
// users open Coco's window, then they can start typing, without needing
|
||||||
|
// to click on the window.
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let _ = app_handle.emit("show-coco", ());
|
let _ = app_handle.emit("show-coco", ());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn hide_coco(app: AppHandle) {
|
async fn hide_coco(app_handle: AppHandle) {
|
||||||
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
cfg_if::cfg_if! {
|
||||||
if let Err(err) = window.hide() {
|
if #[cfg(target_os = "macos")] {
|
||||||
log::error!("Failed to hide the window: {}", err);
|
use tauri_nspanel::ManagerExt;
|
||||||
|
|
||||||
|
let app_handle_clone = app_handle.clone();
|
||||||
|
app_handle.run_on_main_thread(move || {
|
||||||
|
let panel = app_handle_clone.get_webview_panel(MAIN_WINDOW_LABEL).expect("cannot find the main window/panel");
|
||||||
|
panel.hide();
|
||||||
|
}).unwrap();
|
||||||
} else {
|
} else {
|
||||||
log::debug!("Window successfully hidden.");
|
let window = app_handle.get_webview_window(MAIN_WINDOW_LABEL).expect("cannot find the main window");
|
||||||
|
|
||||||
|
if let Err(err) = window.hide() {
|
||||||
|
log::error!("Failed to hide the window: {}", err);
|
||||||
|
} else {
|
||||||
|
log::debug!("Window successfully hidden.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
};
|
||||||
log::error!("Main window not found.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn move_window_to_active_monitor(window: &WebviewWindow) {
|
fn move_window_to_active_monitor(window: &WebviewWindow) {
|
||||||
//dbg!("Moving window to active monitor");
|
let scale_factor = window.scale_factor().unwrap();
|
||||||
// Try to get the available monitors, handle failure gracefully
|
|
||||||
let available_monitors = match window.available_monitors() {
|
|
||||||
Ok(monitors) => monitors,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to get monitors: {}", e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Attempt to get the cursor position, handle failure gracefully
|
let point = window.cursor_position().unwrap();
|
||||||
let cursor_position = match window.cursor_position() {
|
|
||||||
Ok(pos) => Some(pos),
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to get cursor position: {}", e);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Find the monitor that contains the cursor or default to the primary monitor
|
let LogicalPosition { x, y } = point.to_logical(scale_factor);
|
||||||
let target_monitor = if let Some(cursor_position) = cursor_position {
|
|
||||||
// Convert cursor position to integers
|
match window.monitor_from_point(x, y) {
|
||||||
let cursor_x = cursor_position.x.round() as i32;
|
Ok(Some(monitor)) => {
|
||||||
let cursor_y = cursor_position.y.round() as i32;
|
if let Some(name) = monitor.name() {
|
||||||
|
let previous_monitor_name = PREVIOUS_MONITOR_NAME.lock().unwrap();
|
||||||
|
if let Some(ref prev_name) = *previous_monitor_name {
|
||||||
|
if name.to_string() == *prev_name {
|
||||||
|
log::debug!("Currently on the same monitor");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Find the monitor that contains the cursor
|
|
||||||
available_monitors.into_iter().find(|monitor| {
|
|
||||||
let monitor_position = monitor.position();
|
let monitor_position = monitor.position();
|
||||||
let monitor_size = monitor.size();
|
let monitor_size = monitor.size();
|
||||||
|
|
||||||
cursor_x >= monitor_position.x
|
// Current window size for horizontal centering
|
||||||
&& cursor_x <= monitor_position.x + monitor_size.width as i32
|
let window_size = match window.inner_size() {
|
||||||
&& cursor_y >= monitor_position.y
|
Ok(size) => size,
|
||||||
&& cursor_y <= monitor_position.y + monitor_size.height as i32
|
Err(e) => {
|
||||||
})
|
log::error!("Failed to get window size: {}", e);
|
||||||
} else {
|
return;
|
||||||
None
|
}
|
||||||
};
|
};
|
||||||
|
let window_width = window_size.width as i32;
|
||||||
|
|
||||||
// Use the target monitor or default to the primary monitor
|
// Horizontal center uses actual width, vertical center uses 590 baseline
|
||||||
let monitor = match target_monitor.or_else(|| window.primary_monitor().ok().flatten()) {
|
let window_x = monitor_position.x + (monitor_size.width as i32 - window_width) / 2;
|
||||||
Some(monitor) => monitor,
|
let window_y = monitor_position.y
|
||||||
None => {
|
+ (monitor_size.height as i32 - WINDOW_CENTER_BASELINE_HEIGHT) / 2;
|
||||||
log::error!("No monitor found!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(name) = monitor.name() {
|
if let Err(e) = window.set_position(PhysicalPosition::new(window_x, window_y)) {
|
||||||
let previous_monitor_name = PREVIOUS_MONITOR_NAME.lock().unwrap();
|
log::error!("Failed to move window: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(ref prev_name) = *previous_monitor_name {
|
if let Some(name) = monitor.name() {
|
||||||
if name.to_string() == *prev_name {
|
log::debug!("Window moved to monitor: {}", name);
|
||||||
log::debug!("Currently on the same monitor");
|
let mut previous_monitor = PREVIOUS_MONITOR_NAME.lock().unwrap();
|
||||||
|
*previous_monitor = Some(name.to_string());
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Ok(None) => {
|
||||||
|
log::error!("No monitor found at the specified point");
|
||||||
let monitor_position = monitor.position();
|
}
|
||||||
let monitor_size = monitor.size();
|
Err(e) => {
|
||||||
|
log::error!("Failed to get monitor from point: {}", e);
|
||||||
// Get the current size of the window
|
|
||||||
let window_size = match window.inner_size() {
|
|
||||||
Ok(size) => size,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to get window size: {}", e);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let window_width = window_size.width as i32;
|
|
||||||
let window_height = window_size.height as i32;
|
|
||||||
|
|
||||||
// Calculate the new position to center the window on the monitor
|
|
||||||
let window_x = monitor_position.x + (monitor_size.width as i32 - window_width) / 2;
|
|
||||||
let window_y = monitor_position.y + (monitor_size.height as i32 - window_height) / 2;
|
|
||||||
|
|
||||||
// Move the window to the new position
|
|
||||||
if let Err(e) = window.set_position(PhysicalPosition::new(window_x, window_y)) {
|
|
||||||
log::error!("Failed to move window: {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(name) = monitor.name() {
|
|
||||||
log::debug!("Window moved to monitor: {}", name);
|
|
||||||
|
|
||||||
let mut previous_monitor = PREVIOUS_MONITOR_NAME.lock().unwrap();
|
|
||||||
*previous_monitor = Some(name.to_string());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,135 +443,3 @@ async fn hide_check(app_handle: AppHandle) {
|
|||||||
|
|
||||||
window.hide().unwrap();
|
window.hide().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log format:
|
|
||||||
///
|
|
||||||
/// ```text
|
|
||||||
/// [time] [log level] [file module:line] message
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// Example:
|
|
||||||
///
|
|
||||||
///
|
|
||||||
/// ```text
|
|
||||||
/// [05-11 17:00:00] [INF] [coco_lib:625] Coco-AI started
|
|
||||||
/// ```
|
|
||||||
fn set_up_tauri_logger() -> TauriPlugin<tauri::Wry> {
|
|
||||||
use log::Level;
|
|
||||||
use log::LevelFilter;
|
|
||||||
use tauri_plugin_log::Builder;
|
|
||||||
|
|
||||||
/// Coco-AI app's default log level.
|
|
||||||
const DEFAULT_LOG_LEVEL: LevelFilter = LevelFilter::Info;
|
|
||||||
const LOG_LEVEL_ENV_VAR: &str = "COCO_LOG";
|
|
||||||
|
|
||||||
fn format_log_level(level: Level) -> &'static str {
|
|
||||||
match level {
|
|
||||||
Level::Trace => "TRC",
|
|
||||||
Level::Debug => "DBG",
|
|
||||||
Level::Info => "INF",
|
|
||||||
Level::Warn => "WAR",
|
|
||||||
Level::Error => "ERR",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_target_and_line(record: &log::Record) -> String {
|
|
||||||
let mut str = record.target().to_string();
|
|
||||||
if let Some(line) = record.line() {
|
|
||||||
str.push(':');
|
|
||||||
str.push_str(&line.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
str
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Allow us to configure dynamic log levels via environment variable `COCO_LOG`.
|
|
||||||
///
|
|
||||||
/// Generally, it mirros the behavior of `env_logger`. Syntax: `COCO_LOG=[target][=][level][,...]`
|
|
||||||
///
|
|
||||||
/// * If this environment variable is not set, use the default log level.
|
|
||||||
/// * If it is set, respect it:
|
|
||||||
///
|
|
||||||
/// * `COCO_LOG=coco_lib` turns on all logging for the `coco_lib` module, which is
|
|
||||||
/// equivalent to `COCO_LOG=coco_lib=trace`
|
|
||||||
/// * `COCO_LOG=trace` turns on all logging for the application, regardless of its name
|
|
||||||
/// * `COCO_LOG=TRACE` turns on all logging for the application, regardless of its name (same as previous)
|
|
||||||
/// * `COCO_LOG=reqwest=debug` turns on debug logging for `reqwest`
|
|
||||||
/// * `COCO_LOG=trace,tauri=off` turns on all the logging except for the logs come from `tauri`
|
|
||||||
/// * `COCO_LOG=off` turns off all logging for the application
|
|
||||||
/// * `COCO_LOG=` Since the value is empty, turns off all logging for the application as well
|
|
||||||
fn dynamic_log_level(mut builder: Builder) -> Builder {
|
|
||||||
let Some(log_levels) = std::env::var_os(LOG_LEVEL_ENV_VAR) else {
|
|
||||||
return builder.level(DEFAULT_LOG_LEVEL);
|
|
||||||
};
|
|
||||||
|
|
||||||
builder = builder.level(LevelFilter::Off);
|
|
||||||
|
|
||||||
let log_levels = log_levels.into_string().unwrap_or_else(|e| {
|
|
||||||
panic!(
|
|
||||||
"The value '{}' set in environment varaible '{}' is not UTF-8 encoded",
|
|
||||||
// Cannot use `.display()` here becuase that requires MSRV 1.87.0
|
|
||||||
e.to_string_lossy(),
|
|
||||||
LOG_LEVEL_ENV_VAR
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
// COCO_LOG=[target][=][level][,...]
|
|
||||||
let target_log_levels = log_levels.split(',');
|
|
||||||
for target_log_level in target_log_levels {
|
|
||||||
#[allow(clippy::collapsible_else_if)]
|
|
||||||
if let Some(char_index) = target_log_level.chars().position(|c| c == '=') {
|
|
||||||
let (target, equal_sign_and_level) = target_log_level.split_at(char_index);
|
|
||||||
// Remove the equal sign, we know it takes 1 byte
|
|
||||||
let level = &equal_sign_and_level[1..];
|
|
||||||
|
|
||||||
if let Ok(level) = level.parse::<LevelFilter>() {
|
|
||||||
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
|
|
||||||
builder = builder.level_for(target.to_string(), level);
|
|
||||||
} else {
|
|
||||||
panic!(
|
|
||||||
"log level '{}' set in '{}={}' is invalid",
|
|
||||||
level, target, level
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if let Ok(level) = target_log_level.parse::<LevelFilter>() {
|
|
||||||
// This is a level
|
|
||||||
builder = builder.level(level);
|
|
||||||
} else {
|
|
||||||
// This is a target, enable all the logging
|
|
||||||
//
|
|
||||||
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
|
|
||||||
builder = builder.level_for(target_log_level.to_string(), LevelFilter::Trace);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
builder
|
|
||||||
}
|
|
||||||
|
|
||||||
// When running the built binary, set `COCO_LOG` to `coco_lib=trace` to capture all logs
|
|
||||||
// that come from Coco in the log file, which helps with debugging.
|
|
||||||
if !tauri::is_dev() {
|
|
||||||
// We have absolutely no guarantee that we (We have control over the Rust
|
|
||||||
// code, but definitely no idea about the libc C code, all the shared objects
|
|
||||||
// that we will link) will not concurrently read/write `envp`, so just use unsafe.
|
|
||||||
unsafe {
|
|
||||||
std::env::set_var("COCO_LOG", "coco_lib=trace");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut builder = tauri_plugin_log::Builder::new();
|
|
||||||
builder = builder.format(|out, message, record| {
|
|
||||||
let now = chrono::Local::now().format("%m-%d %H:%M:%S");
|
|
||||||
let level = format_log_level(record.level());
|
|
||||||
let target_and_line = format_target_and_line(record);
|
|
||||||
out.finish(format_args!(
|
|
||||||
"[{}] [{}] [{}] {}",
|
|
||||||
now, level, target_and_line, message
|
|
||||||
));
|
|
||||||
});
|
|
||||||
builder = dynamic_log_level(builder);
|
|
||||||
|
|
||||||
builder.build()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,42 +1,9 @@
|
|||||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
use coco_lib::util::logging::app_log_dir;
|
||||||
use std::fs::OpenOptions;
|
use std::fs::OpenOptions;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
/// Helper function to return the log directory.
|
|
||||||
///
|
|
||||||
/// This should return the same value as `tauri_app_handle.path().app_log_dir().unwrap()`.
|
|
||||||
fn app_log_dir() -> PathBuf {
|
|
||||||
// This function `app_log_dir()` is for the panic hook, which should be set
|
|
||||||
// before Tauri performs any initialization. At that point, we do not have
|
|
||||||
// access to the identifier provided by Tauri, so we need to define our own
|
|
||||||
// one here.
|
|
||||||
//
|
|
||||||
// NOTE: If you update identifier in the following files, update this one
|
|
||||||
// as well!
|
|
||||||
//
|
|
||||||
// src-tauri/tauri.linux.conf.json
|
|
||||||
// src-tauri/Entitlements.plist
|
|
||||||
// src-tauri/tauri.conf.json
|
|
||||||
// src-tauri/Info.plist
|
|
||||||
const IDENTIFIER: &str = "rs.coco.app";
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
let path = dirs::home_dir()
|
|
||||||
.expect("cannot find the home directory, Coco should never run in such a environment")
|
|
||||||
.join("Library/Logs")
|
|
||||||
.join(IDENTIFIER);
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
let path = dirs::data_local_dir()
|
|
||||||
.expect("app local dir is None, we should not encounter this")
|
|
||||||
.join(IDENTIFIER)
|
|
||||||
.join("logs");
|
|
||||||
|
|
||||||
path
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set up panic hook to log panic information to a file
|
/// Set up panic hook to log panic information to a file
|
||||||
fn setup_panic_hook() {
|
fn setup_panic_hook() {
|
||||||
|
|||||||
@@ -4,19 +4,18 @@ use crate::common::search::{
|
|||||||
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
|
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
|
||||||
};
|
};
|
||||||
use crate::common::traits::SearchSource;
|
use crate::common::traits::SearchSource;
|
||||||
|
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||||
use crate::server::servers::logout_coco_server;
|
use crate::server::servers::logout_coco_server;
|
||||||
use crate::server::servers::mark_server_as_offline;
|
use crate::server::servers::mark_server_as_offline;
|
||||||
|
use crate::settings::get_local_query_source_weight;
|
||||||
use function_name::named;
|
use function_name::named;
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use futures::stream::FuturesUnordered;
|
use futures::stream::FuturesUnordered;
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use std::cmp::Reverse;
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
use tokio::time::{Duration, timeout};
|
use tokio::time::{Duration, timeout};
|
||||||
|
|
||||||
#[named]
|
#[named]
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn query_coco_fusion(
|
pub async fn query_coco_fusion(
|
||||||
@@ -187,7 +186,6 @@ async fn query_coco_fusion_multi_query_sources(
|
|||||||
|
|
||||||
let mut futures = FuturesUnordered::new();
|
let mut futures = FuturesUnordered::new();
|
||||||
|
|
||||||
let query_source_list_len = query_source_trait_object_list.len();
|
|
||||||
for query_source_trait_object in query_source_trait_object_list {
|
for query_source_trait_object in query_source_trait_object_list {
|
||||||
let query_source = query_source_trait_object.get_type().clone();
|
let query_source = query_source_trait_object.get_type().clone();
|
||||||
let tauri_app_handle_clone = tauri_app_handle.clone();
|
let tauri_app_handle_clone = tauri_app_handle.clone();
|
||||||
@@ -208,14 +206,8 @@ async fn query_coco_fusion_multi_query_sources(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut total_hits = 0;
|
let mut total_hits = 0;
|
||||||
let mut need_rerank = true; //TODO set default to false when boost supported in Pizza
|
|
||||||
let mut failed_requests = Vec::new();
|
let mut failed_requests = Vec::new();
|
||||||
let mut all_hits: Vec<(String, QueryHits, f64)> = Vec::new();
|
let mut all_hits_grouped_by_query_source: HashMap<QuerySource, Vec<QueryHits>> = HashMap::new();
|
||||||
let mut hits_per_source: HashMap<String, Vec<(QueryHits, f64)>> = HashMap::new();
|
|
||||||
|
|
||||||
if query_source_list_len > 1 {
|
|
||||||
need_rerank = true; // If we have more than one source, we need to rerank the hits
|
|
||||||
}
|
|
||||||
|
|
||||||
while let Some((query_source, timeout_result)) = futures.next().await {
|
while let Some((query_source, timeout_result)) = futures.next().await {
|
||||||
match timeout_result {
|
match timeout_result {
|
||||||
@@ -229,7 +221,6 @@ async fn query_coco_fusion_multi_query_sources(
|
|||||||
Ok(query_result) => match query_result {
|
Ok(query_result) => match query_result {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
total_hits += response.total_hits;
|
total_hits += response.total_hits;
|
||||||
let source_id = response.source.id.clone();
|
|
||||||
|
|
||||||
for (document, score) in response.hits {
|
for (document, score) in response.hits {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
@@ -246,12 +237,10 @@ async fn query_coco_fusion_multi_query_sources(
|
|||||||
document,
|
document,
|
||||||
};
|
};
|
||||||
|
|
||||||
all_hits.push((source_id.clone(), query_hit.clone(), score));
|
all_hits_grouped_by_query_source
|
||||||
|
.entry(query_source.clone())
|
||||||
hits_per_source
|
|
||||||
.entry(source_id.clone())
|
|
||||||
.or_insert_with(Vec::new)
|
.or_insert_with(Vec::new)
|
||||||
.push((query_hit, score));
|
.push(query_hit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(search_error) => {
|
Err(search_error) => {
|
||||||
@@ -267,109 +256,129 @@ async fn query_coco_fusion_multi_query_sources(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort hits within each source by score (descending)
|
let n_sources = all_hits_grouped_by_query_source.len();
|
||||||
for hits in hits_per_source.values_mut() {
|
|
||||||
hits.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Greater));
|
if n_sources == 0 {
|
||||||
|
return Ok(MultiSourceQueryResponse {
|
||||||
|
failed: Vec::new(),
|
||||||
|
hits: Vec::new(),
|
||||||
|
total_hits: 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let total_sources = hits_per_source.len();
|
/*
|
||||||
let max_hits_per_source = if total_sources > 0 {
|
* Apply settings: local query source weight
|
||||||
size as usize / total_sources
|
*/
|
||||||
} else {
|
let local_query_source_weight: f64 = get_local_query_source_weight(tauri_app_handle);
|
||||||
size as usize
|
// Scores remain unchanged if it is 1.0
|
||||||
};
|
if local_query_source_weight != 1.0 {
|
||||||
|
for (query_source, hits) in all_hits_grouped_by_query_source.iter_mut() {
|
||||||
|
if query_source.r#type == LOCAL_QUERY_SOURCE_TYPE {
|
||||||
|
hits.iter_mut()
|
||||||
|
.for_each(|hit| hit.score = hit.score * local_query_source_weight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sort hits within each source by score (descending) in case data sources
|
||||||
|
* do not sort them
|
||||||
|
*/
|
||||||
|
for hits in all_hits_grouped_by_query_source.values_mut() {
|
||||||
|
hits.sort_by(|a, b| {
|
||||||
|
b.score
|
||||||
|
.partial_cmp(&a.score)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Greater)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Collect hits evenly across sources, to ensure:
|
||||||
|
*
|
||||||
|
* 1. All sources have hits returned
|
||||||
|
* 2. Query sources with many hits won't dominate
|
||||||
|
*/
|
||||||
|
let mut final_hits_grouped_by_source_id: HashMap<String, Vec<QueryHits>> = HashMap::new();
|
||||||
|
let mut pruned: HashMap<&str, &[QueryHits]> = HashMap::new();
|
||||||
|
|
||||||
|
// Include at least 2 hits from each query source
|
||||||
|
let max_hits_per_source = (size as usize / n_sources).max(2);
|
||||||
|
for (query_source, hits) in all_hits_grouped_by_query_source.iter() {
|
||||||
|
let hits_taken = if hits.len() > max_hits_per_source {
|
||||||
|
pruned.insert(&query_source.id, &hits[max_hits_per_source..]);
|
||||||
|
hits[0..max_hits_per_source].to_vec()
|
||||||
|
} else {
|
||||||
|
hits.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
final_hits_grouped_by_source_id.insert(query_source.id.clone(), hits_taken);
|
||||||
|
}
|
||||||
|
|
||||||
|
let final_hits_len = final_hits_grouped_by_source_id
|
||||||
|
.iter()
|
||||||
|
.fold(0, |acc: usize, (_source_id, hits)| acc + hits.len());
|
||||||
|
let pruned_len = pruned
|
||||||
|
.iter()
|
||||||
|
.fold(0, |acc: usize, (_source_id, hits)| acc + hits.len());
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If we still need more hits, take the highest-scoring from `pruned`
|
||||||
|
*
|
||||||
|
* `pruned` contains sorted arrays, we scan it in a way similar to
|
||||||
|
* how n-way-merge-sort extracts the element with the greatest value.
|
||||||
|
*/
|
||||||
|
if final_hits_len < size as usize {
|
||||||
|
let n_need = size as usize - final_hits_len;
|
||||||
|
let n_have = pruned_len;
|
||||||
|
let n_take = n_have.min(n_need);
|
||||||
|
|
||||||
|
for _ in 0..n_take {
|
||||||
|
let mut highest_score_hit: Option<(&str, &QueryHits)> = None;
|
||||||
|
for (source_id, sorted_hits) in pruned.iter_mut() {
|
||||||
|
if sorted_hits.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hit = &sorted_hits[0];
|
||||||
|
|
||||||
|
let have_higher_score_hit = match highest_score_hit {
|
||||||
|
Some((_, current_highest_score_hit)) => {
|
||||||
|
hit.score > current_highest_score_hit.score
|
||||||
|
}
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if have_higher_score_hit {
|
||||||
|
highest_score_hit = Some((*source_id, hit));
|
||||||
|
|
||||||
|
// Advance sorted_hits by 1 element, if have
|
||||||
|
if sorted_hits.len() == 1 {
|
||||||
|
*sorted_hits = &[];
|
||||||
|
} else {
|
||||||
|
*sorted_hits = &sorted_hits[1..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (source_id, hit) = highest_score_hit.expect("`pruned` should contain at least `n_take` elements so `highest_score_hit` should be set");
|
||||||
|
|
||||||
|
final_hits_grouped_by_source_id
|
||||||
|
.get_mut(source_id)
|
||||||
|
.expect("all the source_ids stored in `pruned` come from `final_hits_grouped_by_source_id`, so it should exist")
|
||||||
|
.push(hit.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Re-rank the final hits
|
||||||
|
*/
|
||||||
|
if n_sources > 1 {
|
||||||
|
boosted_levenshtein_rerank(&query_keyword, &mut final_hits_grouped_by_source_id);
|
||||||
|
}
|
||||||
|
|
||||||
let mut final_hits = Vec::new();
|
let mut final_hits = Vec::new();
|
||||||
let mut seen_docs = HashSet::new(); // To track documents we've already added
|
for (_source_id, hits) in final_hits_grouped_by_source_id {
|
||||||
|
final_hits.extend(hits);
|
||||||
// Distribute hits fairly across sources
|
|
||||||
for (_source_id, hits) in &mut hits_per_source {
|
|
||||||
let take_count = hits.len().min(max_hits_per_source);
|
|
||||||
for (doc, score) in hits.drain(0..take_count) {
|
|
||||||
if !seen_docs.contains(&doc.document.id) {
|
|
||||||
seen_docs.insert(doc.document.id.clone());
|
|
||||||
log::debug!(
|
|
||||||
"collect doc: {}, {:?}, {}",
|
|
||||||
doc.document.id,
|
|
||||||
doc.document.title,
|
|
||||||
score
|
|
||||||
);
|
|
||||||
final_hits.push(doc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::debug!("final hits: {:?}", final_hits.len());
|
|
||||||
|
|
||||||
let mut unique_sources = HashSet::new();
|
|
||||||
for hit in &final_hits {
|
|
||||||
if let Some(source) = &hit.source {
|
|
||||||
if source.id != crate::extension::built_in::calculator::DATA_SOURCE_ID {
|
|
||||||
unique_sources.insert(&source.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::debug!(
|
|
||||||
"Multiple sources found: {:?}, no rerank needed",
|
|
||||||
unique_sources
|
|
||||||
);
|
|
||||||
|
|
||||||
if unique_sources.len() < 1 {
|
|
||||||
need_rerank = false; // If we have hits from multiple sources, we don't need to rerank
|
|
||||||
}
|
|
||||||
|
|
||||||
if need_rerank && final_hits.len() > 1 {
|
|
||||||
// Precollect (index, title)
|
|
||||||
let titles_to_score: Vec<(usize, &str)> = final_hits
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter_map(|(idx, hit)| {
|
|
||||||
let source = hit.source.as_ref()?;
|
|
||||||
let title = hit.document.title.as_deref()?;
|
|
||||||
|
|
||||||
if source.id != crate::extension::built_in::calculator::DATA_SOURCE_ID {
|
|
||||||
Some((idx, title))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Score them
|
|
||||||
let scored_hits = boosted_levenshtein_rerank(query_keyword.as_str(), titles_to_score);
|
|
||||||
|
|
||||||
// Sort descending by score
|
|
||||||
let mut scored_hits = scored_hits;
|
|
||||||
scored_hits.sort_by_key(|&(_, score)| Reverse((score * 1000.0) as u64));
|
|
||||||
|
|
||||||
// Apply new scores to final_hits
|
|
||||||
for (idx, score) in scored_hits.into_iter().take(size as usize) {
|
|
||||||
final_hits[idx].score = score;
|
|
||||||
}
|
|
||||||
} else if final_hits.len() < size as usize {
|
|
||||||
// If we still need more hits, take the highest-scoring remaining ones
|
|
||||||
|
|
||||||
let remaining_needed = size as usize - final_hits.len();
|
|
||||||
|
|
||||||
// Sort all hits by score descending, removing duplicates by document ID
|
|
||||||
all_hits.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
|
|
||||||
|
|
||||||
let extra_hits = all_hits
|
|
||||||
.into_iter()
|
|
||||||
.filter(|(source_id, _, _)| hits_per_source.contains_key(source_id)) // Only take from known sources
|
|
||||||
.filter_map(|(_, doc, _)| {
|
|
||||||
if !seen_docs.contains(&doc.document.id) {
|
|
||||||
seen_docs.insert(doc.document.id.clone());
|
|
||||||
Some(doc)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.take(remaining_needed)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
final_hits.extend(extra_hits);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// **Sort final hits by score descending**
|
// **Sort final hits by score descending**
|
||||||
@@ -379,6 +388,9 @@ async fn query_coco_fusion_multi_query_sources(
|
|||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Truncate `final_hits` in case it contains more than `size` hits
|
||||||
|
final_hits.truncate(size as usize);
|
||||||
|
|
||||||
if final_hits.len() < 5 {
|
if final_hits.len() < 5 {
|
||||||
//TODO: Add a recommendation system to suggest more sources
|
//TODO: Add a recommendation system to suggest more sources
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -395,30 +407,85 @@ async fn query_coco_fusion_multi_query_sources(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn boosted_levenshtein_rerank(query: &str, titles: Vec<(usize, &str)>) -> Vec<(usize, f64)> {
|
use std::collections::HashSet;
|
||||||
use strsim::levenshtein;
|
use strsim::levenshtein;
|
||||||
|
|
||||||
|
fn boosted_levenshtein_rerank(
|
||||||
|
query: &str,
|
||||||
|
all_hits_grouped_by_source_id: &mut HashMap<String, Vec<QueryHits>>,
|
||||||
|
) {
|
||||||
let query_lower = query.to_lowercase();
|
let query_lower = query.to_lowercase();
|
||||||
|
|
||||||
titles
|
for (source_id, hits) in all_hits_grouped_by_source_id.iter_mut() {
|
||||||
.into_iter()
|
// Skip special sources like calculator
|
||||||
.map(|(idx, title)| {
|
if source_id == crate::extension::built_in::calculator::DATA_SOURCE_ID {
|
||||||
let mut score = 0.0;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if title.contains(query) {
|
for hit in hits.iter_mut() {
|
||||||
score += 0.4;
|
let document_title = hit.document.title.as_deref().unwrap_or("");
|
||||||
} else if title.to_lowercase().contains(&query_lower) {
|
let document_title_lowercase = document_title.to_lowercase();
|
||||||
score += 0.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
let dist = levenshtein(&query_lower, &title.to_lowercase());
|
let new_score = {
|
||||||
let max_len = query_lower.len().max(title.len());
|
let mut score = 0.0;
|
||||||
if max_len > 0 {
|
|
||||||
score += (1.0 - (dist as f64 / max_len as f64)) as f32;
|
|
||||||
}
|
|
||||||
|
|
||||||
(idx, score.min(1.0) as f64)
|
// --- Exact or substring boost ---
|
||||||
})
|
if document_title.contains(query) {
|
||||||
|
score += 0.4;
|
||||||
|
} else if document_title_lowercase.contains(&query_lower) {
|
||||||
|
score += 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Levenshtein distance (character similarity) ---
|
||||||
|
let dist = levenshtein(&query_lower, &document_title_lowercase);
|
||||||
|
let max_len = query_lower.len().max(document_title.len());
|
||||||
|
let levenshtein_score = if max_len > 0 {
|
||||||
|
(1.0 - (dist as f64 / max_len as f64)) as f32
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Jaccard similarity (token overlap) ---
|
||||||
|
let jaccard_score = jaccard_similarity(&query_lower, &document_title_lowercase);
|
||||||
|
|
||||||
|
// --- Combine scores (weights adjustable) ---
|
||||||
|
// Levenshtein emphasizes surface similarity
|
||||||
|
// Jaccard emphasizes term overlap (semantic hint)
|
||||||
|
let hybrid_score = 0.7 * levenshtein_score + 0.3 * jaccard_score;
|
||||||
|
|
||||||
|
// --- Apply hybrid score ---
|
||||||
|
score += hybrid_score;
|
||||||
|
|
||||||
|
// --- Limit score range ---
|
||||||
|
score.min(1.0) as f64
|
||||||
|
};
|
||||||
|
|
||||||
|
hit.score = new_score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute token-based Jaccard similarity
|
||||||
|
fn jaccard_similarity(a: &str, b: &str) -> f32 {
|
||||||
|
let a_tokens: HashSet<_> = tokenize(a).into_iter().collect();
|
||||||
|
let b_tokens: HashSet<_> = tokenize(b).into_iter().collect();
|
||||||
|
|
||||||
|
if a_tokens.is_empty() || b_tokens.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let intersection = a_tokens.intersection(&b_tokens).count() as f32;
|
||||||
|
let union = a_tokens.union(&b_tokens).count() as f32;
|
||||||
|
|
||||||
|
intersection / union
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Basic tokenizer (case-insensitive, alphanumeric words only)
|
||||||
|
fn tokenize(text: &str) -> Vec<String> {
|
||||||
|
text.to_lowercase()
|
||||||
|
.split(|c: char| !c.is_alphanumeric())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| s.to_string())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use tauri::AppHandle;
|
|||||||
use tauri_plugin_store::StoreExt;
|
use tauri_plugin_store::StoreExt;
|
||||||
|
|
||||||
const SETTINGS_ALLOW_SELF_SIGNATURE: &str = "settings_allow_self_signature";
|
const SETTINGS_ALLOW_SELF_SIGNATURE: &str = "settings_allow_self_signature";
|
||||||
|
const LOCAL_QUERY_SOURCE_WEIGHT: &str = "local_query_source_weight";
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_allow_self_signature(tauri_app_handle: AppHandle, value: bool) {
|
pub async fn set_allow_self_signature(tauri_app_handle: AppHandle, value: bool) {
|
||||||
@@ -70,3 +71,45 @@ pub fn _get_allow_self_signature(tauri_app_handle: AppHandle) -> bool {
|
|||||||
pub async fn get_allow_self_signature(tauri_app_handle: AppHandle) -> bool {
|
pub async fn get_allow_self_signature(tauri_app_handle: AppHandle) -> bool {
|
||||||
_get_allow_self_signature(tauri_app_handle)
|
_get_allow_self_signature(tauri_app_handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_local_query_source_weight(tauri_app_handle: AppHandle, value: f64) {
|
||||||
|
let store = tauri_app_handle
|
||||||
|
.store(COCO_TAURI_STORE)
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
panic!(
|
||||||
|
"store [{}] not found/loaded, error [{}]",
|
||||||
|
COCO_TAURI_STORE, e
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
store.set(LOCAL_QUERY_SOURCE_WEIGHT, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_local_query_source_weight(tauri_app_handle: AppHandle) -> f64 {
|
||||||
|
// default to 1.0
|
||||||
|
const DEFAULT: f64 = 1.0;
|
||||||
|
|
||||||
|
let store = tauri_app_handle
|
||||||
|
.store(COCO_TAURI_STORE)
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
panic!(
|
||||||
|
"store [{}] not found/loaded, error [{}]",
|
||||||
|
COCO_TAURI_STORE, e
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if !store.has(LOCAL_QUERY_SOURCE_WEIGHT) {
|
||||||
|
store.set(LOCAL_QUERY_SOURCE_WEIGHT, DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
match store
|
||||||
|
.get(LOCAL_QUERY_SOURCE_WEIGHT)
|
||||||
|
.expect("should be Some")
|
||||||
|
{
|
||||||
|
Json::Number(n) => n
|
||||||
|
.as_f64()
|
||||||
|
.unwrap_or_else(|| panic!("setting [{}] should be a f64", LOCAL_QUERY_SOURCE_WEIGHT)),
|
||||||
|
_ => unreachable!("{} should be stored as a number", LOCAL_QUERY_SOURCE_WEIGHT),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
//! credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
|
//! credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
|
||||||
|
|
||||||
use crate::common::MAIN_WINDOW_LABEL;
|
use crate::common::MAIN_WINDOW_LABEL;
|
||||||
use objc2_app_kit::NSNonactivatingPanelMask;
|
use tauri::{AppHandle, Emitter, EventTarget, Manager, WebviewWindow};
|
||||||
use tauri::{AppHandle, Emitter, EventTarget, WebviewWindow};
|
use tauri_nspanel::{CollectionBehavior, PanelLevel, StyleMask, WebviewWindowExt, tauri_panel};
|
||||||
use tauri_nspanel::{WebviewWindowExt, cocoa::appkit::NSWindowCollectionBehavior, panel_delegate};
|
|
||||||
|
|
||||||
const WINDOW_FOCUS_EVENT: &str = "tauri://focus";
|
const WINDOW_FOCUS_EVENT: &str = "tauri://focus";
|
||||||
const WINDOW_BLUR_EVENT: &str = "tauri://blur";
|
const WINDOW_BLUR_EVENT: &str = "tauri://blur";
|
||||||
const WINDOW_MOVED_EVENT: &str = "tauri://move";
|
|
||||||
const WINDOW_RESIZED_EVENT: &str = "tauri://resize";
|
tauri_panel! {
|
||||||
|
panel!(NsPanel {
|
||||||
|
config: {
|
||||||
|
is_floating_panel: true,
|
||||||
|
can_become_key_window: true,
|
||||||
|
can_become_main_window: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
panel_event!(NsPanelEventHandler {
|
||||||
|
window_did_become_key(notification: &NSNotification) -> (),
|
||||||
|
window_did_resign_key(notification: &NSNotification) -> (),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn platform(
|
pub fn platform(
|
||||||
_tauri_app_handle: &AppHandle,
|
_tauri_app_handle: &AppHandle,
|
||||||
@@ -17,68 +29,39 @@ pub fn platform(
|
|||||||
_check_window: WebviewWindow,
|
_check_window: WebviewWindow,
|
||||||
) {
|
) {
|
||||||
// Convert ns_window to ns_panel
|
// Convert ns_window to ns_panel
|
||||||
let panel = main_window.to_panel().unwrap();
|
let panel = main_window.to_panel::<NsPanel>().unwrap();
|
||||||
|
|
||||||
|
// set level
|
||||||
|
panel.set_level(PanelLevel::Utility.value());
|
||||||
|
|
||||||
// Do not steal focus from other windows
|
// Do not steal focus from other windows
|
||||||
//
|
panel.set_style_mask(StyleMask::empty().nonactivating_panel().into());
|
||||||
// Cast is safe
|
|
||||||
panel.set_style_mask(NSNonactivatingPanelMask.0 as i32);
|
|
||||||
// Set its level to NSFloatingWindowLevel to ensure it appears in front of
|
|
||||||
// all normal-level windows
|
|
||||||
//
|
|
||||||
// NOTE: some Chinese input methods use a level between NSDockWindowLevel (20)
|
|
||||||
// and NSMainMenuWindowLevel (24), setting our level above NSDockWindowLevel
|
|
||||||
// would block their window
|
|
||||||
panel.set_floating_panel(true);
|
|
||||||
|
|
||||||
// Open the window in the active workspace and full screen
|
// Open the window in the active workspace and full screen
|
||||||
panel.set_collection_behaviour(
|
panel.set_collection_behavior(
|
||||||
NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace
|
CollectionBehavior::new()
|
||||||
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorStationary
|
.stationary()
|
||||||
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary,
|
.move_to_active_space()
|
||||||
|
.full_screen_auxiliary()
|
||||||
|
.into(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Define the panel's delegate to listen to panel window events
|
let handler = NsPanelEventHandler::new();
|
||||||
let delegate = panel_delegate!(EcoPanelDelegate {
|
|
||||||
window_did_become_key,
|
|
||||||
window_did_resign_key,
|
|
||||||
window_did_resize,
|
|
||||||
window_did_move
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set event listeners for the delegate
|
let window = main_window.clone();
|
||||||
delegate.set_listener(Box::new(move |delegate_name: String| {
|
handler.window_did_become_key(move |_| {
|
||||||
let target = EventTarget::labeled(MAIN_WINDOW_LABEL);
|
let target = EventTarget::labeled(MAIN_WINDOW_LABEL);
|
||||||
|
|
||||||
let window_move_event = || {
|
let _ = window.emit_to(target, WINDOW_FOCUS_EVENT, true);
|
||||||
if let Ok(position) = main_window.outer_position() {
|
});
|
||||||
let _ = main_window.emit_to(target.clone(), WINDOW_MOVED_EVENT, position);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match delegate_name.as_str() {
|
let window = main_window.clone();
|
||||||
// Called when the window gets keyboard focus
|
handler.window_did_resign_key(move |_| {
|
||||||
"window_did_become_key" => {
|
let target = EventTarget::labeled(MAIN_WINDOW_LABEL);
|
||||||
let _ = main_window.emit_to(target, WINDOW_FOCUS_EVENT, true);
|
|
||||||
}
|
|
||||||
// Called when the window loses keyboard focus
|
|
||||||
"window_did_resign_key" => {
|
|
||||||
let _ = main_window.emit_to(target, WINDOW_BLUR_EVENT, true);
|
|
||||||
}
|
|
||||||
// Called when the window size changes
|
|
||||||
"window_did_resize" => {
|
|
||||||
window_move_event();
|
|
||||||
|
|
||||||
if let Ok(size) = main_window.inner_size() {
|
let _ = window.emit_to(target, WINDOW_BLUR_EVENT, true);
|
||||||
let _ = main_window.emit_to(target, WINDOW_RESIZED_EVENT, size);
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
// Called when the window position changes
|
|
||||||
"window_did_move" => window_move_event(),
|
|
||||||
_ => (),
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Set the delegate object for the window to handle window events
|
// Set the delegate object for the window to handle window events
|
||||||
panel.set_delegate(delegate);
|
panel.set_event_handler(Some(handler.as_ref()));
|
||||||
}
|
}
|
||||||
|
|||||||
189
src-tauri/src/util/logging.rs
Normal file
189
src-tauri/src/util/logging.rs
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::plugin::TauriPlugin;
|
||||||
|
|
||||||
|
/// Return the log directory.
|
||||||
|
///
|
||||||
|
/// We use a custom log directory, which is similar to the one used by
|
||||||
|
/// Tauri, except that the "{bundleIdentifier}" will be "Coco AI" rather
|
||||||
|
/// than the real identifier.
|
||||||
|
///
|
||||||
|
/// We do this because our bundle ID ("rs.coco.app") ends with ".app", log directory
|
||||||
|
/// "/Users/xxx/Library/Logs/rs.coco.app" is mistakenly thought as an application
|
||||||
|
/// by Finder on macOS, making it inconvenient to open. We do not want to change the
|
||||||
|
/// bundle identifier. The data directory, which stores all the data, still
|
||||||
|
/// references it. So doing that will be a breaking change. Using a custom log
|
||||||
|
/// directory make more sense.
|
||||||
|
///
|
||||||
|
/// ### Platform-specific
|
||||||
|
///
|
||||||
|
/// |Platform | Value | Example |
|
||||||
|
/// | --------- | -------------------------------------------------------------------| --------------------------------------------|
|
||||||
|
/// | Linux | `$XDG_DATA_HOME/Coco AI/logs` or `$HOME/.local/share/Coco AI/logs` | `/home/alice/.local/share/Coco AI/logs` |
|
||||||
|
/// | macOS/iOS | `{homeDir}/Library/Logs/Coco AI` | `/Users/Alice/Library/Logs/Coco AI` |
|
||||||
|
/// | Windows | `{FOLDERID_LocalAppData}/Coco AI/logs` | `C:\Users\Alice\AppData\Local\Coco AI\logs` |
|
||||||
|
/// | Android | `{ConfigDir}/logs` | `/data/data/com.tauri.dev/files/logs` |
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn app_log_dir() -> PathBuf {
|
||||||
|
const IDENTIFIER: &str = "Coco AI";
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let path = dirs::home_dir()
|
||||||
|
.expect("cannot find the home directory, Coco should never run in such a environment")
|
||||||
|
.join("Library/Logs")
|
||||||
|
.join(IDENTIFIER);
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
let path = dirs::data_local_dir()
|
||||||
|
.expect("app local dir is None, we should not encounter this")
|
||||||
|
.join(IDENTIFIER)
|
||||||
|
.join("logs");
|
||||||
|
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log format:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// [time] [log level] [file module:line] message
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
///
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// [05-11 17:00:00] [INF] [coco_lib:625] Coco-AI started
|
||||||
|
/// ```
|
||||||
|
pub(crate) fn set_up_tauri_logger() -> TauriPlugin<tauri::Wry> {
|
||||||
|
use log::Level;
|
||||||
|
use log::LevelFilter;
|
||||||
|
use tauri_plugin_log::Builder;
|
||||||
|
use tauri_plugin_log::Target;
|
||||||
|
use tauri_plugin_log::TargetKind;
|
||||||
|
|
||||||
|
/// Coco-AI app's default log level.
|
||||||
|
const DEFAULT_LOG_LEVEL: LevelFilter = LevelFilter::Info;
|
||||||
|
const LOG_LEVEL_ENV_VAR: &str = "COCO_LOG";
|
||||||
|
|
||||||
|
fn format_log_level(level: Level) -> &'static str {
|
||||||
|
match level {
|
||||||
|
Level::Trace => "TRC",
|
||||||
|
Level::Debug => "DBG",
|
||||||
|
Level::Info => "INF",
|
||||||
|
Level::Warn => "WAR",
|
||||||
|
Level::Error => "ERR",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_target_and_line(record: &log::Record) -> String {
|
||||||
|
let mut str = record.target().to_string();
|
||||||
|
if let Some(line) = record.line() {
|
||||||
|
str.push(':');
|
||||||
|
str.push_str(&line.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
str
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allow us to configure dynamic log levels via environment variable `COCO_LOG`.
|
||||||
|
///
|
||||||
|
/// Generally, it mirrors the behavior of `env_logger`. Syntax: `COCO_LOG=[module][=][level][,...]`
|
||||||
|
///
|
||||||
|
/// * If this environment variable is not set, use the default log level.
|
||||||
|
/// * If it is set, respect it:
|
||||||
|
///
|
||||||
|
/// * `COCO_LOG=coco_lib` turns on all logging for the `coco_lib` module, which is
|
||||||
|
/// equivalent to `COCO_LOG=coco_lib=trace`
|
||||||
|
/// * `COCO_LOG=trace` turns on all logging for the application, regardless of its name
|
||||||
|
/// * `COCO_LOG=TRACE` turns on all logging for the application, regardless of its name (same as previous)
|
||||||
|
/// * `COCO_LOG=reqwest=debug` turns on debug logging for `reqwest`
|
||||||
|
/// * `COCO_LOG=trace,tauri=off` turns on all the logging except for the logs come from `tauri`
|
||||||
|
/// * `COCO_LOG=off` turns off all logging for the application
|
||||||
|
/// * `COCO_LOG=` Since the value is empty, turns off all logging for the application as well
|
||||||
|
fn dynamic_log_level(mut builder: Builder) -> Builder {
|
||||||
|
let Some(log_levels) = std::env::var_os(LOG_LEVEL_ENV_VAR) else {
|
||||||
|
return builder.level(DEFAULT_LOG_LEVEL);
|
||||||
|
};
|
||||||
|
|
||||||
|
builder = builder.level(LevelFilter::Off);
|
||||||
|
|
||||||
|
let log_levels = log_levels.into_string().unwrap_or_else(|e| {
|
||||||
|
panic!(
|
||||||
|
"The value '{}' set in environment variable '{}' is not UTF-8 encoded",
|
||||||
|
// Cannot use `.display()` here because that requires MSRV 1.87.0
|
||||||
|
e.to_string_lossy(),
|
||||||
|
LOG_LEVEL_ENV_VAR
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
// COCO_LOG=[module][=][level][,...]
|
||||||
|
let module_log_levels = log_levels.split(',');
|
||||||
|
for module_log_level in module_log_levels {
|
||||||
|
#[allow(clippy::collapsible_else_if)]
|
||||||
|
if let Some(char_index) = module_log_level.chars().position(|c| c == '=') {
|
||||||
|
let (module, equal_sign_and_level) = module_log_level.split_at(char_index);
|
||||||
|
// Remove the equal sign, we know it takes 1 byte
|
||||||
|
let level = &equal_sign_and_level[1..];
|
||||||
|
|
||||||
|
if let Ok(level) = level.parse::<LevelFilter>() {
|
||||||
|
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
|
||||||
|
builder = builder.level_for(module.to_string(), level);
|
||||||
|
} else {
|
||||||
|
panic!(
|
||||||
|
"log level '{}' set in '{}={}' is invalid",
|
||||||
|
level, module, level
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let Ok(level) = module_log_level.parse::<LevelFilter>() {
|
||||||
|
// This is a level
|
||||||
|
builder = builder.level(level);
|
||||||
|
} else {
|
||||||
|
// This is a module, enable all the logging
|
||||||
|
let module = module_log_level;
|
||||||
|
// Here we have to call `.to_string()` because `Cow<'static, str>` requires `&'static str`
|
||||||
|
builder = builder.level_for(module.to_string(), LevelFilter::Trace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder
|
||||||
|
}
|
||||||
|
|
||||||
|
// When running the built binary, set `COCO_LOG` to `coco_lib=trace` to capture all logs
|
||||||
|
// that come from Coco in the log file, which helps with debugging.
|
||||||
|
if !tauri::is_dev() {
|
||||||
|
// We have absolutely no guarantee that we (We have control over the Rust
|
||||||
|
// code, but definitely no idea about the libc C code, all the shared objects
|
||||||
|
// that we will link) will not concurrently read/write `envp`, so just use unsafe.
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("COCO_LOG", "coco_lib=trace");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut builder = tauri_plugin_log::Builder::new();
|
||||||
|
builder = builder.format(|out, message, record| {
|
||||||
|
let now = chrono::Local::now().format("%m-%d %H:%M:%S");
|
||||||
|
let level = format_log_level(record.level());
|
||||||
|
let target_and_line = format_target_and_line(record);
|
||||||
|
out.finish(format_args!(
|
||||||
|
"[{}] [{}] [{}] {}",
|
||||||
|
now, level, target_and_line, message
|
||||||
|
));
|
||||||
|
});
|
||||||
|
builder = dynamic_log_level(builder);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Use our custom log directory
|
||||||
|
*/
|
||||||
|
// We have no public APIs to update targets in-place, so we need to remove
|
||||||
|
// them all, then bring back the correct ones.
|
||||||
|
builder = builder.clear_targets();
|
||||||
|
builder = builder.target(Target::new(TargetKind::Stdout));
|
||||||
|
builder = builder.target(Target::new(TargetKind::Folder {
|
||||||
|
path: app_log_dir(),
|
||||||
|
// Use the default value, which is "Coco-AI.log"
|
||||||
|
file_name: None,
|
||||||
|
}));
|
||||||
|
|
||||||
|
builder.build()
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
pub(crate) mod app_lang;
|
pub(crate) mod app_lang;
|
||||||
pub(crate) mod file;
|
pub(crate) mod file;
|
||||||
|
// We need this in main.rs, so it has to be pub
|
||||||
|
pub mod logging;
|
||||||
pub(crate) mod path;
|
pub(crate) mod path;
|
||||||
pub(crate) mod platform;
|
pub(crate) mod platform;
|
||||||
pub(crate) mod prevent_default;
|
pub(crate) mod prevent_default;
|
||||||
pub(crate) mod system_lang;
|
pub(crate) mod system_lang;
|
||||||
pub(crate) mod updater;
|
pub(crate) mod version;
|
||||||
|
|
||||||
use std::{path::Path, process::Command};
|
use std::{path::Path, process::Command};
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
use semver::Version;
|
|
||||||
use tauri_plugin_updater::RemoteRelease;
|
|
||||||
|
|
||||||
/// Helper function to extract the build number out of `version`.
|
|
||||||
///
|
|
||||||
/// If the version string is in the `x.y.z` format and does not include a build
|
|
||||||
/// number, we assume a build number of 0.
|
|
||||||
fn extract_build_number(version: &Version) -> u32 {
|
|
||||||
let pre = &version.pre;
|
|
||||||
|
|
||||||
if pre.is_empty() {
|
|
||||||
// A special value for the versions that do not have array
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
let pre_str = pre.as_str();
|
|
||||||
let build_number_str = {
|
|
||||||
match pre_str.strip_prefix("SNAPSHOT-") {
|
|
||||||
Some(str) => str,
|
|
||||||
None => pre_str,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let build_number : u32 = build_number_str.parse().unwrap_or_else(|e| {
|
|
||||||
panic!(
|
|
||||||
"invalid build number, cannot parse [{}] to a valid build number, error [{}], version [{}]",
|
|
||||||
build_number_str, e, version
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
build_number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// # Local version format
|
|
||||||
///
|
|
||||||
/// Packages built in our CI use the following format:
|
|
||||||
///
|
|
||||||
/// * `x.y.z-SNAPSHOT-<build number>`
|
|
||||||
/// * `x.y.z-<build number>`
|
|
||||||
///
|
|
||||||
/// If you build Coco from src, the version will be in format `x.y.z`
|
|
||||||
///
|
|
||||||
/// # Remote version format
|
|
||||||
///
|
|
||||||
/// `x.y.z-<build number>`
|
|
||||||
///
|
|
||||||
/// # How we compare versions
|
|
||||||
///
|
|
||||||
/// We compare versions based solely on the build number.
|
|
||||||
/// If the version string is in the `x.y.z` format and does not include a build number,
|
|
||||||
/// we assume a build number of 0. As a result, such versions are considered older
|
|
||||||
/// than any version with an explicit build number.
|
|
||||||
pub(crate) fn custom_version_comparator(local: Version, remote_release: RemoteRelease) -> bool {
|
|
||||||
let remote = remote_release.version;
|
|
||||||
|
|
||||||
let local_build_number = extract_build_number(&local);
|
|
||||||
let remote_build_number = extract_build_number(&remote);
|
|
||||||
|
|
||||||
let should_update = remote_build_number > local_build_number;
|
|
||||||
log::debug!(
|
|
||||||
"custom version comparator invoked, local version [{}], remote version [{}], should update [{}]",
|
|
||||||
local,
|
|
||||||
remote,
|
|
||||||
should_update
|
|
||||||
);
|
|
||||||
|
|
||||||
should_update
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_build_number() {
|
|
||||||
// 0.6.0 => 0
|
|
||||||
let version = Version::parse("0.6.0").unwrap();
|
|
||||||
assert_eq!(extract_build_number(&version), 0);
|
|
||||||
|
|
||||||
// 0.6.0-2371 => 2371
|
|
||||||
let version = Version::parse("0.6.0-2371").unwrap();
|
|
||||||
assert_eq!(extract_build_number(&version), 2371);
|
|
||||||
|
|
||||||
// 0.6.0-SNAPSHOT-2371 => 2371
|
|
||||||
let version = Version::parse("0.6.0-SNAPSHOT-2371").unwrap();
|
|
||||||
assert_eq!(extract_build_number(&version), 2371);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
245
src-tauri/src/util/version.rs
Normal file
245
src-tauri/src/util/version.rs
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
use semver::{BuildMetadata, Prerelease, Version as SemVer};
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
use tauri_plugin_updater::RemoteRelease;
|
||||||
|
|
||||||
|
const SNAPSHOT_DASH: &str = "SNAPSHOT-";
|
||||||
|
const SNAPSHOT_DASH_LEN: usize = SNAPSHOT_DASH.len();
|
||||||
|
// trim the last dash
|
||||||
|
const SNAPSHOT: &str = SNAPSHOT_DASH.split_at(SNAPSHOT_DASH_LEN - 1).0;
|
||||||
|
|
||||||
|
/// Coco app version, in SemVer format.
|
||||||
|
pub(crate) static COCO_VERSION: LazyLock<SemVer> = LazyLock::new(|| {
|
||||||
|
parse_coco_semver(env!("CARGO_PKG_VERSION")).expect("parsing should never fail, if version format changes, then parse_coco_semver() should be updated as well")
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Coco AI app adopt SemVer but the version string format does not adhere to
|
||||||
|
/// the SemVer specification, this function does the conversion. Returns `None`
|
||||||
|
/// if the input is not in the expected format so that the conversion cannot
|
||||||
|
/// complete.
|
||||||
|
///
|
||||||
|
/// # Example cases
|
||||||
|
///
|
||||||
|
/// * 0.8.0 => 0.8.0
|
||||||
|
///
|
||||||
|
/// You may see this when you develop Coco locally
|
||||||
|
///
|
||||||
|
/// * 0.8.0-<build num> => 0.8.0
|
||||||
|
///
|
||||||
|
/// This is the official release for 0.8.0
|
||||||
|
///
|
||||||
|
/// * 0.9.0-SNAPSHOT-<build num> => 0.9.0-SNAPSHOT.<build num>
|
||||||
|
///
|
||||||
|
/// A pre-release of 0.9.0
|
||||||
|
fn to_semver(version: &SemVer) -> Option<SemVer> {
|
||||||
|
let pre = &version.pre;
|
||||||
|
|
||||||
|
if pre.is_empty() {
|
||||||
|
return Some(SemVer::new(version.major, version.minor, version.patch));
|
||||||
|
}
|
||||||
|
let is_pre_release = pre.starts_with(SNAPSHOT_DASH);
|
||||||
|
|
||||||
|
let build_number_str = if is_pre_release {
|
||||||
|
&pre[SNAPSHOT_DASH_LEN..]
|
||||||
|
} else {
|
||||||
|
pre.as_str()
|
||||||
|
};
|
||||||
|
// Parse the build number to validate it, we do not need the actual number though.
|
||||||
|
build_number_str.parse::<usize>().ok()?;
|
||||||
|
|
||||||
|
// Return after checking the build number is valid
|
||||||
|
if !is_pre_release {
|
||||||
|
return Some(SemVer::new(version.major, version.minor, version.patch));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pre = {
|
||||||
|
let pre_str = format!("{}.{}", SNAPSHOT, build_number_str);
|
||||||
|
Prerelease::new(&pre_str).unwrap_or_else(|e| panic!("invalid Prerelease: {}", e))
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(SemVer {
|
||||||
|
major: version.major,
|
||||||
|
minor: version.minor,
|
||||||
|
patch: version.patch,
|
||||||
|
pre,
|
||||||
|
build: BuildMetadata::EMPTY,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse Coco version string to a `SemVer`. Returns `None` if it is not a valid
|
||||||
|
/// version string.
|
||||||
|
pub(crate) fn parse_coco_semver(version_str: &str) -> Option<SemVer> {
|
||||||
|
let not_semver = SemVer::parse(version_str).ok()?;
|
||||||
|
to_semver(¬_semver)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn custom_version_comparator(local: SemVer, remote_release: RemoteRelease) -> bool {
|
||||||
|
let remote = remote_release.version;
|
||||||
|
let local_semver = to_semver(&local);
|
||||||
|
let remote_semver = to_semver(&remote);
|
||||||
|
|
||||||
|
let should_update = remote_semver > local_semver;
|
||||||
|
|
||||||
|
log::debug!(
|
||||||
|
"custom version comparator invoked, local version [{}], remote version [{}], should update [{}]",
|
||||||
|
local,
|
||||||
|
remote,
|
||||||
|
should_update
|
||||||
|
);
|
||||||
|
|
||||||
|
should_update
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tauri_plugin_updater::RemoteReleaseInner;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_try_into_semver_local_dev() {
|
||||||
|
// Case: 0.8.0 => 0.8.0
|
||||||
|
// Local development version without any pre-release or build metadata
|
||||||
|
let input = SemVer::parse("0.8.0").unwrap();
|
||||||
|
let result = to_semver(&input).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.major, 0);
|
||||||
|
assert_eq!(result.minor, 8);
|
||||||
|
assert_eq!(result.patch, 0);
|
||||||
|
assert_eq!(result.pre, Prerelease::EMPTY);
|
||||||
|
assert!(result.build.is_empty());
|
||||||
|
assert_eq!(result.to_string(), "0.8.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_try_into_semver_official_release() {
|
||||||
|
// Case: 0.8.0-<build num> => 0.8.0
|
||||||
|
// Official release with build number in pre-release field
|
||||||
|
let input = SemVer::parse("0.8.0-123").unwrap();
|
||||||
|
let result = to_semver(&input).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.major, 0);
|
||||||
|
assert_eq!(result.minor, 8);
|
||||||
|
assert_eq!(result.patch, 0);
|
||||||
|
assert_eq!(result.pre, Prerelease::EMPTY);
|
||||||
|
assert!(result.build.is_empty());
|
||||||
|
assert_eq!(result.to_string(), "0.8.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_try_into_semver_pre_release() {
|
||||||
|
// Case: 0.9.0-SNAPSHOT-<build num> => 0.9.0-SNAPSHOT.<build num>
|
||||||
|
// Pre-release version with SNAPSHOT prefix
|
||||||
|
let input = SemVer::parse("0.9.0-SNAPSHOT-456").unwrap();
|
||||||
|
let result = to_semver(&input).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.major, 0);
|
||||||
|
assert_eq!(result.minor, 9);
|
||||||
|
assert_eq!(result.patch, 0);
|
||||||
|
assert_eq!(result.pre.as_str(), "SNAPSHOT.456");
|
||||||
|
assert!(result.build.is_empty());
|
||||||
|
assert_eq!(result.to_string(), "0.9.0-SNAPSHOT.456");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_try_into_semver_official_release_different_version() {
|
||||||
|
// Test with different version numbers
|
||||||
|
let input = SemVer::parse("1.2.3-9999").unwrap();
|
||||||
|
let result = to_semver(&input).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.major, 1);
|
||||||
|
assert_eq!(result.minor, 2);
|
||||||
|
assert_eq!(result.patch, 3);
|
||||||
|
assert_eq!(result.pre, Prerelease::EMPTY);
|
||||||
|
assert!(result.build.is_empty());
|
||||||
|
assert_eq!(result.to_string(), "1.2.3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_try_into_semver_snapshot_different_version() {
|
||||||
|
// Test SNAPSHOT with different version numbers
|
||||||
|
let input = SemVer::parse("2.0.0-SNAPSHOT-777").unwrap();
|
||||||
|
let result = to_semver(&input).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.major, 2);
|
||||||
|
assert_eq!(result.minor, 0);
|
||||||
|
assert_eq!(result.patch, 0);
|
||||||
|
assert_eq!(result.pre.as_str(), "SNAPSHOT.777");
|
||||||
|
assert!(result.build.is_empty());
|
||||||
|
assert_eq!(result.to_string(), "2.0.0-SNAPSHOT.777");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_try_into_semver_invalid_build_number() {
|
||||||
|
// Should panic when build number is not a valid number
|
||||||
|
let input = SemVer::parse("0.8.0-abc").unwrap();
|
||||||
|
assert!(to_semver(&input).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_try_into_semver_invalid_snapshot_build_number() {
|
||||||
|
// Should panic when SNAPSHOT build number is not a valid number
|
||||||
|
let input = SemVer::parse("0.9.0-SNAPSHOT-xyz").unwrap();
|
||||||
|
assert!(to_semver(&input).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_custom_version_comparator() {
|
||||||
|
fn new_local(str: &str) -> SemVer {
|
||||||
|
SemVer::parse(str).unwrap()
|
||||||
|
}
|
||||||
|
fn new_remote_release(str: &str) -> RemoteRelease {
|
||||||
|
let version = SemVer::parse(str).unwrap();
|
||||||
|
|
||||||
|
RemoteRelease {
|
||||||
|
version,
|
||||||
|
notes: None,
|
||||||
|
pub_date: None,
|
||||||
|
data: RemoteReleaseInner::Static {
|
||||||
|
platforms: HashMap::new(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
custom_version_comparator(new_local("0.8.0"), new_remote_release("0.8.0-2518")),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
custom_version_comparator(new_local("0.8.0-2518"), new_remote_release("0.8.0")),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
custom_version_comparator(new_local("0.9.0-SNAPSHOT-1"), new_remote_release("0.9.0")),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
custom_version_comparator(new_local("0.9.0-SNAPSHOT-1"), new_remote_release("0.8.1")),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
custom_version_comparator(new_local("0.9.0-SNAPSHOT-1"), new_remote_release("0.9.0-2")),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
custom_version_comparator(
|
||||||
|
new_local("0.9.0-SNAPSHOT-1"),
|
||||||
|
new_remote_release("0.9.0-SNAPSHOT-1")
|
||||||
|
),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
custom_version_comparator(
|
||||||
|
new_local("0.9.0-SNAPSHOT-11"),
|
||||||
|
new_remote_release("0.9.0-SNAPSHOT-9")
|
||||||
|
),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
custom_version_comparator(
|
||||||
|
new_local("0.9.0-SNAPSHOT-11"),
|
||||||
|
new_remote_release("0.9.0-SNAPSHOT-19")
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,20 +65,25 @@
|
|||||||
"height": 260,
|
"height": 260,
|
||||||
"minHeight": 260,
|
"minHeight": 260,
|
||||||
"center": false,
|
"center": false,
|
||||||
|
"decorations": false,
|
||||||
"transparent": true,
|
"transparent": true,
|
||||||
"maximizable": false,
|
"maximizable": false,
|
||||||
"skipTaskbar": false,
|
"skipTaskbar": false,
|
||||||
"dragDropEnabled": false,
|
"dragDropEnabled": false,
|
||||||
"hiddenTitle": true,
|
"hiddenTitle": true,
|
||||||
"visible": false,
|
"visible": false,
|
||||||
|
"shadow": false,
|
||||||
"windowEffects": {
|
"windowEffects": {
|
||||||
"effects": ["sidebar"],
|
"effects": ["sidebar"],
|
||||||
"state": "active"
|
"state": "active",
|
||||||
|
"radius": 7
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null,
|
"csp": {
|
||||||
|
"default-src": "'self' asset: http: https: ipc: blob: data:"
|
||||||
|
},
|
||||||
"dangerousDisableAssetCspModification": true,
|
"dangerousDisableAssetCspModification": true,
|
||||||
"assetProtocol": {
|
"assetProtocol": {
|
||||||
"enable": true,
|
"enable": true,
|
||||||
@@ -141,4 +146,4 @@
|
|||||||
},
|
},
|
||||||
"os": {}
|
"os": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
|
axios.defaults.withCredentials = true;
|
||||||
|
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -59,8 +61,19 @@ export const handleApiError = (error: any) => {
|
|||||||
message = error.message;
|
message = error.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const url =
|
||||||
|
error?.config?.url ||
|
||||||
|
error?.response?.config?.url ||
|
||||||
|
error?.request?.config?.url;
|
||||||
|
|
||||||
|
const suppressProfileError =
|
||||||
|
typeof url === "string" && url.includes("/account/profile");
|
||||||
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
addError(message, "error");
|
if (!suppressProfileError) {
|
||||||
|
addError(message, "error");
|
||||||
|
}
|
||||||
|
|
||||||
return error;
|
return error;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,7 +91,7 @@ export const Get = <T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.get(baseURL + url, { params })
|
.get(baseURL + url, { params, withCredentials: true })
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
let res: FcResponse<T>;
|
let res: FcResponse<T>;
|
||||||
if (clearFn !== undefined) {
|
if (clearFn !== undefined) {
|
||||||
@@ -113,6 +126,7 @@ export const Post = <T>(
|
|||||||
.post(baseURL + url, data, {
|
.post(baseURL + url, data, {
|
||||||
params,
|
params,
|
||||||
headers,
|
headers,
|
||||||
|
withCredentials: true,
|
||||||
} as any)
|
} as any)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
resolve([null, result.data as FcResponse<T>]);
|
resolve([null, result.data as FcResponse<T>]);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export async function streamPost({
|
|||||||
...(headersStorage),
|
...(headersStorage),
|
||||||
...(headers || {}),
|
...(headers || {}),
|
||||||
},
|
},
|
||||||
|
credentials: "include",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -30,19 +30,23 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
|||||||
const setCurrentAssistant = useConnectStore((state) => {
|
const setCurrentAssistant = useConnectStore((state) => {
|
||||||
return state.setCurrentAssistant;
|
return state.setCurrentAssistant;
|
||||||
});
|
});
|
||||||
|
const assistantList = useConnectStore((state) => state.assistantList);
|
||||||
|
|
||||||
const aiAssistant = useShortcutsStore((state) => state.aiAssistant);
|
const aiAssistant = useShortcutsStore((state) => state.aiAssistant);
|
||||||
|
|
||||||
const [assistants, setAssistants] = useState<any[]>([]);
|
const [assistants, setAssistants] = useState<any[]>([]);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
const popoverButtonRef = useRef<HTMLButtonElement>(null);
|
const popoverButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
|
|
||||||
const debounceKeyword = useDebounce(keyword, { wait: 500 });
|
const debounceKeyword = useDebounce(keyword, { wait: 500 });
|
||||||
|
|
||||||
const askAiAssistantId = useSearchStore((state) => state.askAiAssistantId);
|
const askAiAssistantId = useSearchStore((state) => state.askAiAssistantId);
|
||||||
const setAskAiAssistantId = useSearchStore((state) => {
|
const setAskAiAssistantId = useSearchStore((state) => {
|
||||||
return state.setAskAiAssistantId;
|
return state.setAskAiAssistantId;
|
||||||
});
|
});
|
||||||
const assistantList = useConnectStore((state) => state.assistantList);
|
|
||||||
|
|
||||||
const { fetchAssistant } = AssistantFetcher({
|
const { fetchAssistant } = AssistantFetcher({
|
||||||
debounceKeyword,
|
debounceKeyword,
|
||||||
@@ -221,7 +225,7 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
|||||||
autoFocus
|
autoFocus
|
||||||
value={keyword}
|
value={keyword}
|
||||||
placeholder={t("assistant.popover.search")}
|
placeholder={t("assistant.popover.search")}
|
||||||
className="w-full h-8 px-2 bg-transparent border rounded-md dark:border-white/10"
|
className="w-full h-8 px-2 bg-transparent border rounded-[6px] dark:border-white/10"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setKeyword(event.target.value);
|
setKeyword(event.target.value);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ interface ChatAIProps {
|
|||||||
startPage?: StartPage;
|
startPage?: StartPage;
|
||||||
formatUrl?: (data: any) => string;
|
formatUrl?: (data: any) => string;
|
||||||
instanceId?: string;
|
instanceId?: string;
|
||||||
|
getChatHistoryChatPage?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendMessageParams {
|
export interface SendMessageParams {
|
||||||
@@ -52,6 +53,7 @@ export interface ChatAIRef {
|
|||||||
init: (params: SendMessageParams) => void;
|
init: (params: SendMessageParams) => void;
|
||||||
cancelChat: () => void;
|
cancelChat: () => void;
|
||||||
clearChat: () => void;
|
clearChat: () => void;
|
||||||
|
onSelectChat: (chat: Chat) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatAI = memo(
|
const ChatAI = memo(
|
||||||
@@ -73,6 +75,7 @@ const ChatAI = memo(
|
|||||||
startPage,
|
startPage,
|
||||||
formatUrl,
|
formatUrl,
|
||||||
instanceId,
|
instanceId,
|
||||||
|
getChatHistoryChatPage,
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
@@ -80,6 +83,7 @@ const ChatAI = memo(
|
|||||||
init: init,
|
init: init,
|
||||||
cancelChat: () => cancelChat(activeChat),
|
cancelChat: () => cancelChat(activeChat),
|
||||||
clearChat: clearChat,
|
clearChat: clearChat,
|
||||||
|
onSelectChat: onSelectChat,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const curChatEnd = useChatStore((state) => state.curChatEnd);
|
const curChatEnd = useChatStore((state) => state.curChatEnd);
|
||||||
@@ -116,6 +120,12 @@ const ChatAI = memo(
|
|||||||
activeChatProp && setActiveChat(activeChatProp);
|
activeChatProp && setActiveChat(activeChatProp);
|
||||||
}, [activeChatProp]);
|
}, [activeChatProp]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { setHasActiveChat } = useChatStore.getState();
|
||||||
|
|
||||||
|
setHasActiveChat(Boolean(activeChat));
|
||||||
|
}, [activeChat]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTauri) return;
|
if (!isTauri) return;
|
||||||
|
|
||||||
@@ -193,7 +203,8 @@ const ChatAI = memo(
|
|||||||
isDeepThinkActive,
|
isDeepThinkActive,
|
||||||
isMCPActive,
|
isMCPActive,
|
||||||
changeInput,
|
changeInput,
|
||||||
showChatHistory
|
showChatHistory,
|
||||||
|
getChatHistoryChatPage
|
||||||
);
|
);
|
||||||
|
|
||||||
const { dealMsg } = useMessageHandler(
|
const { dealMsg } = useMessageHandler(
|
||||||
@@ -377,7 +388,7 @@ const ChatAI = memo(
|
|||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
data-chat-instance={instanceId}
|
data-chat-instance={instanceId}
|
||||||
className={`flex flex-col rounded-md h-full overflow-hidden relative`}
|
className={`flex flex-col rounded-[6px] h-full overflow-hidden relative`}
|
||||||
>
|
>
|
||||||
<ChatHeader
|
<ChatHeader
|
||||||
clearChat={clearChat}
|
clearChat={clearChat}
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import { ChatMessage } from "@/components/ChatMessage";
|
|||||||
import { Greetings } from "./Greetings";
|
import { Greetings } from "./Greetings";
|
||||||
import AttachmentList from "@/components/Assistant/AttachmentList";
|
import AttachmentList from "@/components/Assistant/AttachmentList";
|
||||||
import { useChatScroll } from "@/hooks/useChatScroll";
|
import { useChatScroll } from "@/hooks/useChatScroll";
|
||||||
|
|
||||||
import type { Chat, IChunkData } from "@/types/chat";
|
import type { Chat, IChunkData } from "@/types/chat";
|
||||||
import { useConnectStore } from "@/stores/connectStore";
|
import { useConnectStore } from "@/stores/connectStore";
|
||||||
// import SessionFile from "./SessionFile";
|
// import SessionFile from "./SessionFile";
|
||||||
import ScrollToBottom from "@/components/Common/ScrollToBottom";
|
import ScrollToBottom from "@/components/Common/ScrollToBottom";
|
||||||
import { useChatStore } from "@/stores/chatStore";
|
import { useChatStore } from "@/stores/chatStore";
|
||||||
|
import { useWebConfigStore } from "@/stores/webConfigStore";
|
||||||
|
import { useAppStore } from "@/stores/appStore";
|
||||||
|
import { NoResults } from "../Common/UI/NoResults";
|
||||||
|
|
||||||
interface ChatContentProps {
|
interface ChatContentProps {
|
||||||
activeChat?: Chat;
|
activeChat?: Chat;
|
||||||
@@ -45,20 +47,23 @@ export const ChatContent = ({
|
|||||||
handleSendMessage,
|
handleSendMessage,
|
||||||
formatUrl,
|
formatUrl,
|
||||||
}: ChatContentProps) => {
|
}: ChatContentProps) => {
|
||||||
const { currentSessionId, setCurrentSessionId } = useConnectStore();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { uploadAttachments } = useChatStore();
|
const currentSessionId = useConnectStore((state) => state.currentSessionId);
|
||||||
|
const setCurrentSessionId = useConnectStore(
|
||||||
|
(state) => state.setCurrentSessionId
|
||||||
|
);
|
||||||
|
const visibleStartPage = useConnectStore((state) => state.visibleStartPage);
|
||||||
|
|
||||||
|
const uploadAttachments = useChatStore((state) => state.uploadAttachments);
|
||||||
|
const curChatEnd = useChatStore((state) => state.curChatEnd);
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { scrollToBottom } = useChatScroll(messagesEndRef);
|
const { scrollToBottom } = useChatScroll(messagesEndRef);
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||||
const visibleStartPage = useConnectStore((state) => state.visibleStartPage);
|
|
||||||
|
|
||||||
const curChatEnd = useChatStore((state) => state.curChatEnd);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsAtBottom(true);
|
setIsAtBottom(true);
|
||||||
@@ -95,87 +100,100 @@ export const ChatContent = ({
|
|||||||
setIsAtBottom(isAtBottom);
|
setIsAtBottom(isAtBottom);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { isTauri } = useAppStore();
|
||||||
|
const { disabled } = useWebConfigStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-hidden flex flex-col justify-between relative user-select-text">
|
<div className="flex-1 overflow-hidden flex flex-col justify-between relative user-select-text">
|
||||||
<div
|
{!isTauri && disabled ? (
|
||||||
ref={scrollRef}
|
<NoResults />
|
||||||
className="flex-1 w-full overflow-x-hidden overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar relative"
|
) : (
|
||||||
onScroll={handleScroll}
|
<>
|
||||||
>
|
<div
|
||||||
{(!activeChat || activeChat?.messages?.length === 0) &&
|
ref={scrollRef}
|
||||||
!visibleStartPage && <Greetings />}
|
className="flex-1 w-full overflow-x-hidden overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar relative"
|
||||||
|
onScroll={handleScroll}
|
||||||
|
>
|
||||||
|
{(!activeChat || activeChat?.messages?.length === 0) &&
|
||||||
|
!visibleStartPage && <Greetings />}
|
||||||
|
|
||||||
{activeChat?.messages?.map((message, index) => (
|
{activeChat?.messages?.map((message, index) => (
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
key={message._id + index}
|
key={message._id + index}
|
||||||
message={message}
|
message={message}
|
||||||
isTyping={false}
|
isTyping={false}
|
||||||
onResend={handleSendMessage}
|
onResend={handleSendMessage}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{(!curChatEnd ||
|
{(!curChatEnd ||
|
||||||
query_intent ||
|
query_intent ||
|
||||||
tools ||
|
tools ||
|
||||||
fetch_source ||
|
fetch_source ||
|
||||||
pick_source ||
|
pick_source ||
|
||||||
deep_read ||
|
deep_read ||
|
||||||
think ||
|
think ||
|
||||||
response) &&
|
response) &&
|
||||||
activeChat?._source?.id ? (
|
activeChat?._source?.id ? (
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
key={"current"}
|
key={"current"}
|
||||||
message={{
|
message={{
|
||||||
_id: "current",
|
_id: "current",
|
||||||
_source: {
|
_source: {
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
assistant_id:
|
assistant_id:
|
||||||
allMessages[allMessages.length - 1]?._source?.assistant_id,
|
allMessages[allMessages.length - 1]?._source
|
||||||
message: "",
|
?.assistant_id,
|
||||||
question: Question,
|
message: "",
|
||||||
},
|
question: Question,
|
||||||
}}
|
},
|
||||||
onResend={handleSendMessage}
|
}}
|
||||||
isTyping={!curChatEnd}
|
onResend={handleSendMessage}
|
||||||
query_intent={query_intent}
|
isTyping={!curChatEnd}
|
||||||
tools={tools}
|
query_intent={query_intent}
|
||||||
fetch_source={fetch_source}
|
tools={tools}
|
||||||
pick_source={pick_source}
|
fetch_source={fetch_source}
|
||||||
deep_read={deep_read}
|
pick_source={pick_source}
|
||||||
think={think}
|
deep_read={deep_read}
|
||||||
response={response}
|
think={think}
|
||||||
loadingStep={loadingStep}
|
response={response}
|
||||||
formatUrl={formatUrl}
|
loadingStep={loadingStep}
|
||||||
/>
|
formatUrl={formatUrl}
|
||||||
) : null}
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{timedoutShow ? (
|
{timedoutShow ? (
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
key={"timedout"}
|
key={"timedout"}
|
||||||
message={{
|
message={{
|
||||||
_id: "timedout",
|
_id: "timedout",
|
||||||
_source: {
|
_source: {
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
message: t("assistant.chat.timedout"),
|
message: t("assistant.chat.timedout"),
|
||||||
question: Question,
|
question: Question,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
onResend={handleSendMessage}
|
onResend={handleSendMessage}
|
||||||
isTyping={false}
|
isTyping={false}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{uploadAttachments.length > 0 && (
|
{uploadAttachments.length > 0 && (
|
||||||
<div key={currentSessionId} className="max-h-[120px] overflow-auto p-2">
|
<div
|
||||||
<AttachmentList />
|
key={currentSessionId}
|
||||||
</div>
|
className="max-h-[120px] overflow-auto p-2"
|
||||||
|
>
|
||||||
|
<AttachmentList />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* {currentSessionId && <SessionFile sessionId={currentSessionId} />} */}
|
||||||
|
|
||||||
|
<ScrollToBottom scrollRef={scrollRef} isAtBottom={isAtBottom} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* {currentSessionId && <SessionFile sessionId={currentSessionId} />} */}
|
|
||||||
|
|
||||||
<ScrollToBottom scrollRef={scrollRef} isAtBottom={isAtBottom} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { MessageSquarePlus } from "lucide-react";
|
import { MessageSquarePlus } from "lucide-react";
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
import HistoryIcon from "@/icons/History";
|
import HistoryIcon from "@/icons/History";
|
||||||
import PinOffIcon from "@/icons/PinOff";
|
|
||||||
import PinIcon from "@/icons/Pin";
|
|
||||||
import WindowsFullIcon from "@/icons/WindowsFull";
|
import WindowsFullIcon from "@/icons/WindowsFull";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
import type { Chat } from "@/types/chat";
|
import type { Chat } from "@/types/chat";
|
||||||
@@ -12,6 +9,8 @@ import { useShortcutsStore } from "@/stores/shortcutsStore";
|
|||||||
import { HISTORY_PANEL_ID } from "@/constants";
|
import { HISTORY_PANEL_ID } from "@/constants";
|
||||||
import { AssistantList } from "./AssistantList";
|
import { AssistantList } from "./AssistantList";
|
||||||
import { ServerList } from "./ServerList";
|
import { ServerList } from "./ServerList";
|
||||||
|
import TogglePin from "../Common/TogglePin";
|
||||||
|
import WebLogin from "../WebLogin";
|
||||||
|
|
||||||
interface ChatHeaderProps {
|
interface ChatHeaderProps {
|
||||||
clearChat: () => void;
|
clearChat: () => void;
|
||||||
@@ -34,21 +33,9 @@ export function ChatHeader({
|
|||||||
showChatHistory = true,
|
showChatHistory = true,
|
||||||
assistantIDs,
|
assistantIDs,
|
||||||
}: ChatHeaderProps) {
|
}: ChatHeaderProps) {
|
||||||
const { isPinned, setIsPinned, isTauri } = useAppStore();
|
const { isTauri } = useAppStore();
|
||||||
|
|
||||||
const { historicalRecords, newSession, fixedWindow, external } =
|
const { historicalRecords, newSession, external } = useShortcutsStore();
|
||||||
useShortcutsStore();
|
|
||||||
|
|
||||||
const togglePin = async () => {
|
|
||||||
try {
|
|
||||||
const { isPinned } = useAppStore.getState();
|
|
||||||
|
|
||||||
setIsPinned(!isPinned);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to toggle window pin state:", err);
|
|
||||||
setIsPinned(isPinned);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
@@ -77,7 +64,7 @@ export function ChatHeader({
|
|||||||
|
|
||||||
<AssistantList assistantIDs={assistantIDs} />
|
<AssistantList assistantIDs={assistantIDs} />
|
||||||
|
|
||||||
{showChatHistory ? (
|
{showChatHistory && (
|
||||||
<button
|
<button
|
||||||
onClick={clearChat}
|
onClick={clearChat}
|
||||||
className="p-2 py-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
className="p-2 py-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||||
@@ -90,7 +77,7 @@ export function ChatHeader({
|
|||||||
<MessageSquarePlus className="h-4 w-4 relative top-0.5" />
|
<MessageSquarePlus className="h-4 w-4 relative top-0.5" />
|
||||||
</VisibleKey>
|
</VisibleKey>
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="max-w-[calc(100%-200px)] text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
<h2 className="max-w-[calc(100%-200px)] text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
@@ -101,16 +88,7 @@ export function ChatHeader({
|
|||||||
|
|
||||||
{isTauri ? (
|
{isTauri ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<TogglePin className="inline-flex" />
|
||||||
onClick={togglePin}
|
|
||||||
className={clsx("inline-flex", {
|
|
||||||
"text-blue-500": isPinned,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}>
|
|
||||||
{isPinned ? <PinIcon /> : <PinOffIcon />}
|
|
||||||
</VisibleKey>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<ServerList clearChat={clearChat} />
|
<ServerList clearChat={clearChat} />
|
||||||
|
|
||||||
@@ -123,7 +101,7 @@ export function ChatHeader({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div />
|
<WebLogin panelClassName="top-8 right-0" />
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const ConnectPrompt = () => {
|
|||||||
<p className="mb-4 w-[388px]">{t("assistant.chat.connect_tip")}</p>
|
<p className="mb-4 w-[388px]">{t("assistant.chat.connect_tip")}</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-2 px-6 py-2 rounded-md text-[#0072ff] transition-colors"
|
className="flex items-center gap-2 px-6 py-2 rounded-[6px] text-[#0072ff] transition-colors"
|
||||||
onClick={handleConnect}
|
onClick={handleConnect}
|
||||||
>
|
>
|
||||||
<span>{t("assistant.chat.connect")}</span>
|
<span>{t("assistant.chat.connect")}</span>
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import StatusIndicator from "@/components/Cloud/StatusIndicator";
|
|||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
import { useSearchStore } from "@/stores/searchStore";
|
import { useSearchStore } from "@/stores/searchStore";
|
||||||
import { useServers } from "@/hooks/useServers";
|
import { useServers } from "@/hooks/useServers";
|
||||||
import { getCurrentWindowService, setCurrentWindowService } from "@/commands/windowService";
|
import {
|
||||||
|
getCurrentWindowService,
|
||||||
|
setCurrentWindowService,
|
||||||
|
} from "@/commands/windowService";
|
||||||
|
|
||||||
interface ServerListProps {
|
interface ServerListProps {
|
||||||
clearChat: () => void;
|
clearChat: () => void;
|
||||||
@@ -33,10 +36,9 @@ export function ServerList({ clearChat }: ServerListProps) {
|
|||||||
);
|
);
|
||||||
const setEndpoint = useAppStore((state) => state.setEndpoint);
|
const setEndpoint = useAppStore((state) => state.setEndpoint);
|
||||||
const isTauri = useAppStore((state) => state.isTauri);
|
const isTauri = useAppStore((state) => state.isTauri);
|
||||||
|
|
||||||
const currentService = useConnectStore((state) => state.currentService);
|
const currentService = useConnectStore((state) => state.currentService);
|
||||||
const cloudSelectService = useConnectStore((state) => {
|
const serverList = useConnectStore((state) => state.serverList);
|
||||||
return state.cloudSelectService;
|
|
||||||
});
|
|
||||||
|
|
||||||
const { setMessages } = useChatStore();
|
const { setMessages } = useChatStore();
|
||||||
|
|
||||||
@@ -55,7 +57,6 @@ export function ServerList({ clearChat }: ServerListProps) {
|
|||||||
const serverListButtonRef = useRef<HTMLButtonElement>(null);
|
const serverListButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const { refreshServerList } = useServers();
|
const { refreshServerList } = useServers();
|
||||||
const serverList = useConnectStore((state) => state.serverList);
|
|
||||||
|
|
||||||
const switchServer = async (server: IServer) => {
|
const switchServer = async (server: IServer) => {
|
||||||
if (!server) return;
|
if (!server) return;
|
||||||
@@ -95,8 +96,10 @@ export function ServerList({ clearChat }: ServerListProps) {
|
|||||||
} else {
|
} else {
|
||||||
switchServer(enabledServers[enabledServers.length - 1]);
|
switchServer(enabledServers[enabledServers.length - 1]);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
setCurrentWindowService({});
|
||||||
}
|
}
|
||||||
}, [currentService?.id, cloudSelectService?.id, serverList]);
|
}, [serverList]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!askAiServerId || serverList.length === 0) return;
|
if (!askAiServerId || serverList.length === 0) return;
|
||||||
@@ -194,7 +197,7 @@ export function ServerList({ clearChat }: ServerListProps) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={openSettings}
|
onClick={openSettings}
|
||||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
|
className="p-1 rounded-[6px] hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||||
>
|
>
|
||||||
<VisibleKey shortcut=",">
|
<VisibleKey shortcut=",">
|
||||||
<Settings className="h-4 w-4 text-[#0287FF]" />
|
<Settings className="h-4 w-4 text-[#0287FF]" />
|
||||||
@@ -202,7 +205,7 @@ export function ServerList({ clearChat }: ServerListProps) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
|
className="p-1 rounded-[6px] hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||||
disabled={isRefreshing}
|
disabled={isRefreshing}
|
||||||
>
|
>
|
||||||
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
|
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
|
||||||
@@ -229,11 +232,11 @@ export function ServerList({ clearChat }: ServerListProps) {
|
|||||||
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 overflow-hidden min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<img
|
<img
|
||||||
src={server?.provider?.icon || logoImg}
|
src={server?.provider?.icon || logoImg}
|
||||||
alt={server.name}
|
alt={server.name}
|
||||||
className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800"
|
className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const target = e.target as HTMLImageElement;
|
const target = e.target as HTMLImageElement;
|
||||||
target.src = logoImg;
|
target.src = logoImg;
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ const SessionFile = (props: SessionFileProps) => {
|
|||||||
|
|
||||||
const getUploadedFiles = async () => {
|
const getUploadedFiles = async () => {
|
||||||
if (isTauri) {
|
if (isTauri) {
|
||||||
console.log("sessionId", sessionId);
|
|
||||||
|
|
||||||
const response: any = await platformAdapter.commands(
|
const response: any = await platformAdapter.commands(
|
||||||
"get_attachment_by_ids",
|
"get_attachment_by_ids",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -72,8 +72,6 @@ const Splash = ({ assistantIDs = [], startPage }: SplashProps) => {
|
|||||||
setSettings(response);
|
setSettings(response);
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("currentService", currentService);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getSettings();
|
getSettings();
|
||||||
fetchData();
|
fetchData();
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function DataSourceItem({ name, icon, connector }: DataSourceItemProps) {
|
|||||||
{icon?.startsWith("font_") ? (
|
{icon?.startsWith("font_") ? (
|
||||||
<FontIcon name={icon} className="size-6" />
|
<FontIcon name={icon} className="size-6" />
|
||||||
) : (
|
) : (
|
||||||
<img src={getTypeIcon()} alt={name} className="size-6" />
|
<img src={getTypeIcon()} alt={name} className="size-6 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span className="font-medium text-gray-900 dark:text-white">
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { FC, memo, useCallback, useEffect, useState } from "react";
|
|||||||
import { Copy } from "lucide-react";
|
import { Copy } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { useDebounceFn } from "ahooks";
|
||||||
|
|
||||||
import { UserProfile } from "./UserProfile";
|
import { UserProfile } from "./UserProfile";
|
||||||
import { OpenURLWithBrowser } from "@/utils";
|
import { OpenURLWithBrowser } from "@/utils";
|
||||||
@@ -20,7 +21,6 @@ const ServiceAuth = memo(
|
|||||||
({ setRefreshLoading, refreshClick }: ServiceAuthProps) => {
|
({ setRefreshLoading, refreshClick }: ServiceAuthProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const language = useAppStore((state) => state.language);
|
|
||||||
const addError = useAppStore((state) => state.addError);
|
const addError = useAppStore((state) => state.addError);
|
||||||
const ssoRequestID = useAppStore((state) => state.ssoRequestID);
|
const ssoRequestID = useAppStore((state) => state.ssoRequestID);
|
||||||
const setSSORequestID = useAppStore((state) => state.setSSORequestID);
|
const setSSORequestID = useAppStore((state) => state.setSSORequestID);
|
||||||
@@ -61,19 +61,21 @@ const ServiceAuth = memo(
|
|||||||
[logoutServer]
|
[logoutServer]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { run: debouncedAuthSuccess } = useDebounceFn((event) => {
|
||||||
|
const { serverId } = event.payload;
|
||||||
|
if (serverId) {
|
||||||
|
refreshClick(serverId, () => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
addError(t("cloud.connect.hints.loginSuccess"), "info");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// handle oauth success event
|
// handle oauth success event
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unlistenOAuth = platformAdapter.listenEvent(
|
const unlistenOAuth = platformAdapter.listenEvent(
|
||||||
"oauth_success",
|
"oauth_success",
|
||||||
(event) => {
|
debouncedAuthSuccess
|
||||||
const { serverId } = event.payload;
|
|
||||||
if (serverId) {
|
|
||||||
refreshClick(serverId, () => {
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
addError(language === "zh" ? "登录成功" : "Login Success", "info");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -163,7 +165,7 @@ const LoginButton: FC<LoginButtonProps> = memo((props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors mb-3"
|
className="px-6 py-2 bg-blue-500 text-white rounded-[6px] hover:bg-blue-600 transition-colors mb-3"
|
||||||
onClick={LoginClick}
|
onClick={LoginClick}
|
||||||
aria-label={t("cloud.login")}
|
aria-label={t("cloud.login")}
|
||||||
>
|
>
|
||||||
@@ -182,9 +184,9 @@ const LoadingState: FC<LoadingStateProps> = memo((props) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2 mb-3">
|
||||||
<button
|
<button
|
||||||
className="px-6 py-2 text-white bg-red-500 rounded-md hover:bg-red-600 transition-colors mb-3"
|
className="px-6 py-2 text-white bg-red-500 rounded-[6px] hover:bg-red-600 transition-colors"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
>
|
>
|
||||||
{t("cloud.cancel")}
|
{t("cloud.cancel")}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
|
|||||||
<img
|
<img
|
||||||
src={item?.provider?.icon || cocoLogoImg}
|
src={item?.provider?.icon || cocoLogoImg}
|
||||||
alt={`${item.name} logo`}
|
alt={`${item.name} logo`}
|
||||||
className="w-5 h-5 flex-shrink-0"
|
className="w-5 h-5 flex-shrink-0 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const target = e.target as HTMLImageElement;
|
const target = e.target as HTMLImageElement;
|
||||||
target.src = cocoLogoImg;
|
target.src = cocoLogoImg;
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ export function UserProfile({ server, userInfo, onLogout }: UserProfileProps) {
|
|||||||
<img
|
<img
|
||||||
src={userInfo?.avatar}
|
src={userInfo?.avatar}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-6 h-6"
|
className="w-6 h-6 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
|
||||||
onError={() => {
|
onError={() => {
|
||||||
setImageLoadError(true);
|
setImageLoadError(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<User className="w-6 h-6 text-gray-500 dark:text-gray-400" />
|
<User className="w-6 h-6 text-gray-500 dark:text-gray-400 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
@@ -62,13 +62,13 @@ const ApiDetails: React.FC = () => {
|
|||||||
{logs.map((log, index) => (
|
{logs.map((log, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="p-4 border rounded-md shadow-sm bg-gray-50"
|
className="p-4 border rounded-[6px] shadow-sm bg-gray-50"
|
||||||
>
|
>
|
||||||
<h4 className="font-semibold text-gray-800">
|
<h4 className="font-semibold text-gray-800">
|
||||||
Latest Request {index + 1}:
|
Latest Request {index + 1}:
|
||||||
</h4>
|
</h4>
|
||||||
<div className="text-sm text-gray-700 mt-1">
|
<div className="text-sm text-gray-700 mt-1">
|
||||||
<pre className="bg-gray-100 p-2 rounded-md whitespace-pre-wrap">
|
<pre className="bg-gray-100 p-2 rounded-[6px] whitespace-pre-wrap">
|
||||||
{JSON.stringify(log.request, null, 2)}
|
{JSON.stringify(log.request, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,7 +87,7 @@ const ApiDetails: React.FC = () => {
|
|||||||
</h4>
|
</h4>
|
||||||
{showIndex === index ? (
|
{showIndex === index ? (
|
||||||
<div className="text-sm text-gray-700 mt-1">
|
<div className="text-sm text-gray-700 mt-1">
|
||||||
<pre className="bg-green-100 p-2 rounded-md text-green-700 whitespace-pre-wrap">
|
<pre className="bg-green-100 p-2 rounded-[6px] text-green-700 whitespace-pre-wrap">
|
||||||
{JSON.stringify(log.response, null, 2)}
|
{JSON.stringify(log.response, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,7 +98,7 @@ const ApiDetails: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<h4 className="font-semibold text-red-800 mt-4">Error:</h4>
|
<h4 className="font-semibold text-red-800 mt-4">Error:</h4>
|
||||||
<div className="text-sm text-gray-700 mt-1">
|
<div className="text-sm text-gray-700 mt-1">
|
||||||
<pre className="bg-red-100 p-2 rounded-md text-red-700 whitespace-pre-wrap">
|
<pre className="bg-red-100 p-2 rounded-[6px] text-red-700 whitespace-pre-wrap">
|
||||||
{JSON.stringify(log.error, null, 2)}
|
{JSON.stringify(log.error, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const ChatSwitch: React.FC<ChatSwitchProps> = ({ isChatMode, onChange }) => {
|
|||||||
<div
|
<div
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={isChatMode}
|
aria-checked={isChatMode}
|
||||||
className={`relative flex items-center justify-between w-10 h-[18px] rounded-full cursor-pointer transition-colors duration-300 ${
|
className={`relative flex items-center justify-between w-10 h-[20px] rounded-full cursor-pointer transition-colors duration-300 ${
|
||||||
isChatMode ? "bg-[#0072ff]" : "bg-[var(--coco-primary-color)]"
|
isChatMode ? "bg-[#0072ff]" : "bg-[var(--coco-primary-color)]"
|
||||||
}`}
|
}`}
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
@@ -39,8 +39,8 @@ const ChatSwitch: React.FC<ChatSwitchProps> = ({ isChatMode, onChange }) => {
|
|||||||
{!isChatMode ? <Search className="w-4 h-4 text-white" /> : <div></div>}
|
{!isChatMode ? <Search className="w-4 h-4 text-white" /> : <div></div>}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`absolute top-[1px] h-4 w-4 bg-white rounded-full shadow-md transform transition-transform duration-300 ${
|
className={`absolute top-[1px] left-[1px] h-[18px] w-[18px] bg-white rounded-full shadow-md transform transition-transform duration-300 ${
|
||||||
isChatMode ? "translate-x-6" : "translate-x-0"
|
isChatMode ? "translate-x-5" : "translate-x-0"
|
||||||
}`}
|
}`}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const ErrorNotification = ({
|
|||||||
}: ErrorNotificationProps) => {
|
}: ErrorNotificationProps) => {
|
||||||
const errors = useAppStore((state) => state.errors);
|
const errors = useAppStore((state) => state.errors);
|
||||||
const removeError = useAppStore((state) => state.removeError);
|
const removeError = useAppStore((state) => state.removeError);
|
||||||
|
const suppressErrors = useAppStore((state) => state.suppressErrors);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoClose) return;
|
if (!autoClose) return;
|
||||||
@@ -32,7 +33,7 @@ const ErrorNotification = ({
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [errors, duration, autoClose]);
|
}, [errors, duration, autoClose]);
|
||||||
|
|
||||||
if (errors.length === 0) return null;
|
if (errors.length === 0 || suppressErrors) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ const HistoryListItem: FC<HistoryListItemProps> = ({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={label}
|
key={label}
|
||||||
className="flex items-center gap-2 px-3 py-2 text-sm rounded-md hover:bg-[#EDEDED] dark:hover:bg-[#2B2C31] transition"
|
className="flex items-center gap-2 px-3 py-2 text-sm rounded-[6px] hover:bg-[#EDEDED] dark:hover:bg-[#2B2C31] transition"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<VisibleKey shortcut={shortcut} onKeyPress={onClick}>
|
<VisibleKey shortcut={shortcut} onKeyPress={onClick}>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function CommonIcon({
|
|||||||
// Handle regular icon types
|
// Handle regular icon types
|
||||||
const renderIconByType = (renderType: string) => {
|
const renderIconByType = (renderType: string) => {
|
||||||
if (isNil(isAbsolute)) return null;
|
if (isNil(isAbsolute)) return null;
|
||||||
|
|
||||||
switch (renderType) {
|
switch (renderType) {
|
||||||
case "special_icon": {
|
case "special_icon": {
|
||||||
if (item.id === "Calculator") {
|
if (item.id === "Calculator") {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface FontIconProps {
|
|||||||
|
|
||||||
const FontIcon: FC<FontIconProps> = ({ name, className, style, ...rest }) => {
|
const FontIcon: FC<FontIconProps> = ({ name, className, style, ...rest }) => {
|
||||||
return (
|
return (
|
||||||
<svg className={`icon ${className || ""}`} style={style} {...rest}>
|
<svg className={`icon dark:drop-shadow-[0_0_6px_rgb(255,255,255)] ${className || ""}`} style={style} {...rest}>
|
||||||
<use xlinkHref={`#${name}`} />
|
<use xlinkHref={`#${name}`} />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ function ThemedIcon({ component: Component, className = "" }: ThemedIconProps) {
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <Component className={className} color={color} />;
|
return (
|
||||||
|
<Component
|
||||||
|
className={`dark:drop-shadow-[0_0_6px_rgb(255,255,255)] ${className}`}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ThemedIcon;
|
export default ThemedIcon;
|
||||||
|
|||||||
@@ -49,7 +49,13 @@ function UniversalIcon({
|
|||||||
|
|
||||||
// Render image type icon
|
// Render image type icon
|
||||||
const renderImageIcon = (src: string) => {
|
const renderImageIcon = (src: string) => {
|
||||||
const img = <img className={className} src={src} alt="icon" />;
|
const img = (
|
||||||
|
<img
|
||||||
|
className={`dark:drop-shadow-[0_0_6px_rgb(255,255,255)] ${className}`}
|
||||||
|
src={src}
|
||||||
|
alt="icon"
|
||||||
|
/>
|
||||||
|
);
|
||||||
return wrapWithIconWrapper ? (
|
return wrapWithIconWrapper ? (
|
||||||
<IconWrapper className={className} onClick={onClick}>
|
<IconWrapper className={className} onClick={onClick}>
|
||||||
{img}
|
{img}
|
||||||
@@ -63,7 +69,7 @@ function UniversalIcon({
|
|||||||
const renderAppIcon = (src: string) => {
|
const renderAppIcon = (src: string) => {
|
||||||
const img = (
|
const img = (
|
||||||
<img
|
<img
|
||||||
className={className}
|
className={`dark:drop-shadow-[0_0_6px_rgb(255,255,255)] ${className}`}
|
||||||
src={platformAdapter.convertFileSrc(src)}
|
src={platformAdapter.convertFileSrc(src)}
|
||||||
alt="icon"
|
alt="icon"
|
||||||
/>
|
/>
|
||||||
|
|||||||
47
src/components/Common/Scrollbar.tsx
Normal file
47
src/components/Common/Scrollbar.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useEventListener } from "ahooks";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
HTMLAttributes,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
const Scrollbar = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||||
|
(props, ref) => {
|
||||||
|
const { children, className, ...rest } = props;
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => containerRef.current as HTMLDivElement);
|
||||||
|
|
||||||
|
useEventListener("keydown", (event) => {
|
||||||
|
const { key } = event;
|
||||||
|
|
||||||
|
if (key !== "PageDown" && key !== "PageUp") return;
|
||||||
|
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const delta = key === "PageDown" ? 1 : -1;
|
||||||
|
const el = containerRef.current;
|
||||||
|
|
||||||
|
el.scrollBy({
|
||||||
|
top: delta * el.clientHeight * 0.9,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...rest}
|
||||||
|
ref={containerRef}
|
||||||
|
className={clsx("custom-scrollbar", className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Scrollbar;
|
||||||
50
src/components/Common/TogglePin.tsx
Normal file
50
src/components/Common/TogglePin.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useAppStore } from "@/stores/appStore";
|
||||||
|
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import VisibleKey from "./VisibleKey";
|
||||||
|
import { FC, HTMLAttributes } from "react";
|
||||||
|
import PinOffIcon from "@/icons/PinOff";
|
||||||
|
import PinIcon from "@/icons/Pin";
|
||||||
|
|
||||||
|
interface TogglePinProps extends HTMLAttributes<HTMLButtonElement> {
|
||||||
|
setIsPinnedWeb?: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TogglePin: FC<TogglePinProps> = (props) => {
|
||||||
|
const { className, setIsPinnedWeb } = props;
|
||||||
|
const { isPinned, setIsPinned } = useAppStore();
|
||||||
|
const { fixedWindow } = useShortcutsStore();
|
||||||
|
|
||||||
|
const togglePin = async () => {
|
||||||
|
const { isTauri, isPinned } = useAppStore.getState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextPinned = !isPinned;
|
||||||
|
|
||||||
|
if (!isTauri) {
|
||||||
|
setIsPinnedWeb?.(nextPinned);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPinned(nextPinned);
|
||||||
|
} catch (err) {
|
||||||
|
setIsPinned(isPinned);
|
||||||
|
|
||||||
|
console.error("Failed to toggle window pin state:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={togglePin}
|
||||||
|
className={clsx(className, {
|
||||||
|
"text-blue-500": isPinned,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}>
|
||||||
|
{isPinned ? <PinIcon /> : <PinOffIcon />}
|
||||||
|
</VisibleKey>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TogglePin;
|
||||||
@@ -27,7 +27,7 @@ const Tooltip2: FC<Tooltip2Props> = (props) => {
|
|||||||
static
|
static
|
||||||
anchor={anchor}
|
anchor={anchor}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"fixed z-1000 p-2 rounded-md text-xs text-white bg-black/75 hidden",
|
"fixed z-1000 p-2 rounded-[6px] text-xs text-white bg-black/75 hidden",
|
||||||
{
|
{
|
||||||
"!block": visible,
|
"!block": visible,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,10 @@ import { useTranslation } from "react-i18next";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
import CommonIcon from "@/components/Common/Icons/CommonIcon";
|
import CommonIcon from "@/components/Common/Icons/CommonIcon";
|
||||||
import Copyright from "@/components/Common/Copyright";
|
|
||||||
import PinOffIcon from "@/icons/PinOff";
|
|
||||||
import PinIcon from "@/icons/Pin";
|
|
||||||
import logoImg from "@/assets/icon.svg";
|
import logoImg from "@/assets/icon.svg";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
import { useSearchStore } from "@/stores/searchStore";
|
import { useSearchStore } from "@/stores/searchStore";
|
||||||
import { useUpdateStore } from "@/stores/updateStore";
|
import { useUpdateStore } from "@/stores/updateStore";
|
||||||
import VisibleKey from "../VisibleKey";
|
|
||||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||||
import { formatKey } from "@/utils/keyboardUtils";
|
import { formatKey } from "@/utils/keyboardUtils";
|
||||||
import source_default_img from "@/assets/images/source_default.png";
|
import source_default_img from "@/assets/images/source_default.png";
|
||||||
@@ -19,6 +15,8 @@ import source_default_dark_img from "@/assets/images/source_default_dark.png";
|
|||||||
import { useThemeStore } from "@/stores/themeStore";
|
import { useThemeStore } from "@/stores/themeStore";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import FontIcon from "../Icons/FontIcon";
|
import FontIcon from "../Icons/FontIcon";
|
||||||
|
import TogglePin from "../TogglePin";
|
||||||
|
import WebLogin from "@/components/WebLogin";
|
||||||
|
|
||||||
interface FooterProps {
|
interface FooterProps {
|
||||||
setIsPinnedWeb?: (value: boolean) => void;
|
setIsPinnedWeb?: (value: boolean) => void;
|
||||||
@@ -37,28 +35,11 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
|
|||||||
|
|
||||||
const isDark = useThemeStore((state) => state.isDark);
|
const isDark = useThemeStore((state) => state.isDark);
|
||||||
|
|
||||||
const { isTauri, isPinned, setIsPinned } = useAppStore();
|
const { isTauri } = useAppStore();
|
||||||
|
|
||||||
const { setVisible, updateInfo, skipVersions } = useUpdateStore();
|
const { setVisible, updateInfo, skipVersions } = useUpdateStore();
|
||||||
|
|
||||||
const { fixedWindow, modifierKey } = useShortcutsStore();
|
const { modifierKey } = useShortcutsStore();
|
||||||
|
|
||||||
const togglePin = async () => {
|
|
||||||
try {
|
|
||||||
const { isTauri, isPinned } = useAppStore.getState();
|
|
||||||
|
|
||||||
const nextPinned = !isPinned;
|
|
||||||
|
|
||||||
if (!isTauri) {
|
|
||||||
setIsPinnedWeb?.(nextPinned);
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsPinned(nextPinned);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to toggle window pin state:", err);
|
|
||||||
setIsPinned(isPinned);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openSetting = useCallback(() => {
|
const openSetting = useCallback(() => {
|
||||||
return platformAdapter.emitEvent("open_settings", "");
|
return platformAdapter.emitEvent("open_settings", "");
|
||||||
@@ -68,7 +49,7 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
|
|||||||
return updateInfo && !skipVersions.includes(updateInfo.version);
|
return updateInfo && !skipVersions.includes(updateInfo.version);
|
||||||
}, [updateInfo, skipVersions]);
|
}, [updateInfo, skipVersions]);
|
||||||
|
|
||||||
const renderLeft = () => {
|
const renderTauriLeft = () => {
|
||||||
if (sourceData?.source?.name) {
|
if (sourceData?.source?.name) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -88,7 +69,10 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
|
|||||||
if (visibleExtensionDetail && selectedExtension) {
|
if (visibleExtensionDetail && selectedExtension) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img src={selectedExtension.icon} className="size-5" />
|
<img
|
||||||
|
src={selectedExtension.icon}
|
||||||
|
className="size-5 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
|
||||||
|
/>
|
||||||
<span className="text-sm">{selectedExtension.name}</span>
|
<span className="text-sm">{selectedExtension.name}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -132,28 +116,28 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region={isTauri}
|
data-tauri-drag-region={isTauri}
|
||||||
className="px-4 z-999 mx-[1px] h-8 absolute bottom-0 left-0 right-0 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between rounded-md rounded-t-none overflow-hidden"
|
className={clsx(
|
||||||
|
"px-4 z-999 mx-[1px] h-8 absolute bottom-0 left-0 right-0 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between rounded-[6px] rounded-t-none",
|
||||||
|
{
|
||||||
|
"overflow-hidden": isTauri,
|
||||||
|
}
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{isTauri ? (
|
{isTauri ? (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
{renderLeft()}
|
{renderTauriLeft()}
|
||||||
|
|
||||||
<button
|
<TogglePin
|
||||||
onClick={togglePin}
|
|
||||||
className={clsx({
|
className={clsx({
|
||||||
"text-blue-500": isPinned,
|
|
||||||
"pl-2": hasUpdate,
|
"pl-2": hasUpdate,
|
||||||
})}
|
})}
|
||||||
>
|
setIsPinnedWeb={setIsPinnedWeb}
|
||||||
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}>
|
/>
|
||||||
{isPinned ? <PinIcon /> : <PinOffIcon />}
|
|
||||||
</VisibleKey>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Copyright />
|
<WebLogin panelClassName="bottom-5 left-0" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`flex mobile:hidden items-center gap-3`}>
|
<div className={`flex mobile:hidden items-center gap-3`}>
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { useShortcutsStore } from "@/stores/shortcutsStore";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { formatKey } from "@/utils/keyboardUtils";
|
import { formatKey } from "@/utils/keyboardUtils";
|
||||||
import SearchEmpty from "../SearchEmpty";
|
import SearchEmpty from "../SearchEmpty";
|
||||||
|
import FontIcon from "../Icons/FontIcon";
|
||||||
|
import WebLoginButton from "@/components/WebLogin/LoginButton";
|
||||||
|
import WebRefreshButton from "@/components/WebLogin/RefreshButton";
|
||||||
|
import { useWebConfigStore } from "@/stores/webConfigStore";
|
||||||
|
import { useAppStore } from "@/stores/appStore";
|
||||||
|
|
||||||
export const NoResults = () => {
|
export const NoResults = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -12,33 +17,66 @@ export const NoResults = () => {
|
|||||||
const modifierKey = useShortcutsStore((state) => state.modifierKey);
|
const modifierKey = useShortcutsStore((state) => state.modifierKey);
|
||||||
const modeSwitch = useShortcutsStore((state) => state.modeSwitch);
|
const modeSwitch = useShortcutsStore((state) => state.modeSwitch);
|
||||||
|
|
||||||
|
const { isTauri } = useAppStore();
|
||||||
|
const { disabled } = useWebConfigStore();
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (!isTauri && disabled) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4 text-sm">
|
||||||
|
<FontIcon
|
||||||
|
name="font_coco-logo-line"
|
||||||
|
className="size-20 text-[#999]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<p>{t("webLogin.hints.welcome")}</p>
|
||||||
|
<p>{t("webLogin.hints.pleaseLogin")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<WebLoginButton />
|
||||||
|
|
||||||
|
<WebRefreshButton className="size-8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SearchEmpty />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex mobile:hidden mt-10 text-sm text-[#333] dark:text-[#D8D8D8]`}
|
||||||
|
>
|
||||||
|
{t("search.main.askCoco")}
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"ml-3 h-5 min-w-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center",
|
||||||
|
{
|
||||||
|
"px-1": !isMac,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatKey(modifierKey)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="ml-1 w-5 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
|
||||||
|
{modeSwitch}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className="h-full w-full flex flex-col justify-center items-center"
|
className="h-full w-full flex flex-col justify-center items-center"
|
||||||
>
|
>
|
||||||
<SearchEmpty />
|
{renderContent()}
|
||||||
|
|
||||||
<div
|
|
||||||
className={`flex mobile:hidden mt-10 text-sm text-[#333] dark:text-[#D8D8D8]`}
|
|
||||||
>
|
|
||||||
{t("search.main.askCoco")}
|
|
||||||
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"ml-3 h-5 min-w-5 rounded-md border border-[#D8D8D8] flex justify-center items-center",
|
|
||||||
{
|
|
||||||
"px-1": !isMac,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formatKey(modifierKey)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="ml-1 w-5 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
|
|
||||||
{modeSwitch}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const Footer = () => {
|
|||||||
active
|
active
|
||||||
? "bg-gray-100 dark:bg-gray-700"
|
? "bg-gray-100 dark:bg-gray-700"
|
||||||
: "text-gray-900 dark:text-gray-100"
|
: "text-gray-900 dark:text-gray-100"
|
||||||
} group flex w-full items-center rounded-md px-3 py-2 text-sm`}
|
} group flex w-full items-center rounded-[6px] px-3 py-2 text-sm`}
|
||||||
>
|
>
|
||||||
<Home className="w-4 h-4 mr-2" />
|
<Home className="w-4 h-4 mr-2" />
|
||||||
<Link to={`/`}>Home</Link>
|
<Link to={`/`}>Home</Link>
|
||||||
@@ -41,7 +41,7 @@ const Footer = () => {
|
|||||||
active
|
active
|
||||||
? "bg-gray-100 dark:bg-gray-700"
|
? "bg-gray-100 dark:bg-gray-700"
|
||||||
: "text-gray-900 dark:text-gray-100"
|
: "text-gray-900 dark:text-gray-100"
|
||||||
} group flex w-full items-center rounded-md px-3 py-2 text-sm`}
|
} group flex w-full items-center rounded-[6px] px-3 py-2 text-sm`}
|
||||||
>
|
>
|
||||||
<User className="w-4 h-4 mr-2" />
|
<User className="w-4 h-4 mr-2" />
|
||||||
Profile
|
Profile
|
||||||
@@ -55,7 +55,7 @@ const Footer = () => {
|
|||||||
active
|
active
|
||||||
? "bg-gray-100 dark:bg-gray-700"
|
? "bg-gray-100 dark:bg-gray-700"
|
||||||
: "text-gray-900 dark:text-gray-100"
|
: "text-gray-900 dark:text-gray-100"
|
||||||
} group flex w-full items-center rounded-md px-3 py-2 text-sm`}
|
} group flex w-full items-center rounded-[6px] px-3 py-2 text-sm`}
|
||||||
>
|
>
|
||||||
<Settings className="w-4 h-4 mr-2" />
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
<Link to={`settings`}>Settings</Link>
|
<Link to={`settings`}>Settings</Link>
|
||||||
@@ -70,7 +70,7 @@ const Footer = () => {
|
|||||||
active
|
active
|
||||||
? "bg-gray-100 dark:bg-gray-700"
|
? "bg-gray-100 dark:bg-gray-700"
|
||||||
: "text-gray-900 dark:text-gray-100"
|
: "text-gray-900 dark:text-gray-100"
|
||||||
} group flex w-full items-center rounded-md px-3 py-2 text-sm`}
|
} group flex w-full items-center rounded-[6px] px-3 py-2 text-sm`}
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4 mr-2" />
|
<LogOut className="w-4 h-4 mr-2" />
|
||||||
Sign Out
|
Sign Out
|
||||||
@@ -95,4 +95,4 @@ const Footer = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Footer;
|
export default Footer;
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { last } from "lodash-es";
|
|||||||
import { POPOVER_PANEL_SELECTOR } from "@/constants";
|
import { POPOVER_PANEL_SELECTOR } from "@/constants";
|
||||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
|
import { KeyType } from "ahooks/lib/useKeyPress";
|
||||||
|
|
||||||
|
const keyTriggerMap = new Map<KeyType, number>();
|
||||||
|
|
||||||
interface VisibleKeyProps extends HTMLAttributes<HTMLDivElement> {
|
interface VisibleKeyProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
shortcut: string;
|
shortcut: string;
|
||||||
@@ -60,8 +63,16 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
|
|||||||
setVisibleShortcut(isChildInPopover && modifierKeyPressed);
|
setVisibleShortcut(isChildInPopover && modifierKeyPressed);
|
||||||
}, [openPopover, modifierKeyPressed]);
|
}, [openPopover, modifierKeyPressed]);
|
||||||
|
|
||||||
useKeyPress(`${modifierKey}.${shortcut}`, (event) => {
|
useKeyPress(`${modifierKey}.${shortcut}`, (event, key) => {
|
||||||
if (!visibleShortcut) return;
|
if (!visibleShortcut || event.repeat) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const last = keyTriggerMap.get(key) ?? 0;
|
||||||
|
const wait = 100;
|
||||||
|
|
||||||
|
if (now - last < wait) return;
|
||||||
|
|
||||||
|
keyTriggerMap.set(key, now);
|
||||||
|
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -82,6 +93,10 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
|
|||||||
return "↩︎";
|
return "↩︎";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shortcut === "backspace") {
|
||||||
|
return "⌫";
|
||||||
|
}
|
||||||
|
|
||||||
return shortcut;
|
return shortcut;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,7 +111,7 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
|
|||||||
{showTooltip && visibleShortcut ? (
|
{showTooltip && visibleShortcut ? (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"size-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
|
"size-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-[6px] shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
|
||||||
shortcutClassName
|
shortcutClassName
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const AiOverview: FC<AiSummaryProps> = (props) => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute top-2 right-2 flex items-center justify-center size-[20px] border rounded-md cursor-pointer dark:border-[#282828]"
|
className="absolute top-2 right-2 flex items-center justify-center size-[20px] border rounded-[6px] cursor-pointer dark:border-[#282828]"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ const AskAi: FC<AskAiProps> = (props) => {
|
|||||||
unlisten.current = await platformAdapter.listenEvent(
|
unlisten.current = await platformAdapter.listenEvent(
|
||||||
"quick-ai-access-client-id",
|
"quick-ai-access-client-id",
|
||||||
({ payload }) => {
|
({ payload }) => {
|
||||||
console.log("ask_ai", JSON.parse(payload));
|
// console.log("ask_ai", JSON.parse(payload));
|
||||||
|
|
||||||
const chunkData = JSON.parse(payload);
|
const chunkData = JSON.parse(payload);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import platformAdapter from "@/utils/platformAdapter";
|
|||||||
import { Get } from "@/api/axiosRequest";
|
import { Get } from "@/api/axiosRequest";
|
||||||
import type { Assistant } from "@/types/chat";
|
import type { Assistant } from "@/types/chat";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
|
import { canNavigateBack, navigateBack } from "@/utils";
|
||||||
|
import { useKeyPress } from "ahooks";
|
||||||
|
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||||
|
|
||||||
interface AssistantManagerProps {
|
interface AssistantManagerProps {
|
||||||
isChatMode: boolean;
|
isChatMode: boolean;
|
||||||
@@ -33,9 +36,9 @@ export function useAssistantManager({
|
|||||||
setVisibleExtensionStore,
|
setVisibleExtensionStore,
|
||||||
setSearchValue,
|
setSearchValue,
|
||||||
visibleExtensionDetail,
|
visibleExtensionDetail,
|
||||||
setVisibleExtensionDetail,
|
|
||||||
sourceData,
|
sourceData,
|
||||||
setSourceData,
|
setSourceData,
|
||||||
|
setVisibleExtensionDetail,
|
||||||
} = useSearchStore();
|
} = useSearchStore();
|
||||||
|
|
||||||
const { quickAiAccessAssistant, disabledExtensions } = useExtensionsStore();
|
const { quickAiAccessAssistant, disabledExtensions } = useExtensionsStore();
|
||||||
@@ -47,6 +50,7 @@ export function useAssistantManager({
|
|||||||
}, [quickAiAccessAssistant, selectedAssistant]);
|
}, [quickAiAccessAssistant, selectedAssistant]);
|
||||||
|
|
||||||
const [assistantDetail, setAssistantDetail] = useState<any>({});
|
const [assistantDetail, setAssistantDetail] = useState<any>({});
|
||||||
|
const { modifierKey } = useShortcutsStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (goAskAi) return;
|
if (goAskAi) return;
|
||||||
@@ -99,39 +103,14 @@ export function useAssistantManager({
|
|||||||
const handleKeyDownAutoResizeTextarea = useCallback(
|
const handleKeyDownAutoResizeTextarea = useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
const { key, shiftKey, currentTarget } = e;
|
const { key, shiftKey, currentTarget } = e;
|
||||||
const { value } = currentTarget;
|
const { value, selectionStart, selectionEnd } = currentTarget;
|
||||||
|
|
||||||
if (key === "Backspace" && value === "") {
|
const cursorStart = selectionStart === 0 && selectionEnd === 0;
|
||||||
if (goAskAi) {
|
|
||||||
return setGoAskAi(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visibleExtensionDetail) {
|
if (key === "Backspace" && (value === "" || cursorStart)) {
|
||||||
return setVisibleExtensionDetail(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visibleExtensionStore) {
|
|
||||||
return setVisibleExtensionStore(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sourceData) {
|
|
||||||
return setSourceData(void 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === "Tab" && !isChatMode && isTauri) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (visibleExtensionStore) return;
|
return navigateBack();
|
||||||
|
|
||||||
if (selectedSearchContent?.id === "Extension Store") {
|
|
||||||
changeInput("");
|
|
||||||
setSearchValue("");
|
|
||||||
return setVisibleExtensionStore(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
assistant_get();
|
|
||||||
return handleAskAi();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === "Enter" && !shiftKey) {
|
if (key === "Enter" && !shiftKey) {
|
||||||
@@ -147,6 +126,17 @@ export function useAssistantManager({
|
|||||||
|
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === "Home") {
|
||||||
|
e.preventDefault();
|
||||||
|
return currentTarget.setSelectionRange(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "End") {
|
||||||
|
e.preventDefault();
|
||||||
|
const length = currentTarget.value.length;
|
||||||
|
return currentTarget.setSelectionRange(length, length);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
isChatMode,
|
isChatMode,
|
||||||
@@ -161,6 +151,66 @@ export function useAssistantManager({
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const clearSearchValue = () => {
|
||||||
|
changeInput("");
|
||||||
|
setSearchValue("");
|
||||||
|
};
|
||||||
|
|
||||||
|
// useKeyPress("backspace", () => {
|
||||||
|
// console.log("backspace");
|
||||||
|
// dispatchEvent("Backspace", 8, "#search-textarea");
|
||||||
|
// });
|
||||||
|
|
||||||
|
useKeyPress("tab", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const { selectedSearchContent, visibleExtensionStore } =
|
||||||
|
useSearchStore.getState();
|
||||||
|
|
||||||
|
console.log("selectedSearchContent", selectedSearchContent);
|
||||||
|
|
||||||
|
const { id, type, category } = selectedSearchContent ?? {};
|
||||||
|
|
||||||
|
if (isChatMode || !isTauri || id === "Calculator") return;
|
||||||
|
|
||||||
|
if (visibleExtensionStore) {
|
||||||
|
clearSearchValue();
|
||||||
|
return setVisibleExtensionDetail(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id === "Extension Store") {
|
||||||
|
clearSearchValue();
|
||||||
|
return setVisibleExtensionStore(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category === "View") {
|
||||||
|
const onOpened = selectedSearchContent?.on_opened;
|
||||||
|
|
||||||
|
if (onOpened?.Extension?.ty?.View) {
|
||||||
|
clearSearchValue();
|
||||||
|
return platformAdapter.invokeBackend("open", {
|
||||||
|
onOpened: onOpened,
|
||||||
|
extraArgs: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "AI Assistant") {
|
||||||
|
assistant_get();
|
||||||
|
return handleAskAi();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSourceData(selectedSearchContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
useKeyPress(`${modifierKey}.enter`, () => {
|
||||||
|
if (canNavigateBack()) return;
|
||||||
|
|
||||||
|
assistant_get();
|
||||||
|
|
||||||
|
return handleAskAi();
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
askAI,
|
askAI,
|
||||||
askAIRef,
|
askAIRef,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useAppStore } from "@/stores/appStore";
|
||||||
|
import { useWebConfigStore } from "@/stores/webConfigStore";
|
||||||
import { useBoolean } from "ahooks";
|
import { useBoolean } from "ahooks";
|
||||||
import {
|
import {
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
@@ -101,6 +103,9 @@ const AutoResizeTextarea = forwardRef<
|
|||||||
[setInput]
|
[setInput]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { isTauri } = useAppStore();
|
||||||
|
const { disabled } = useWebConfigStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -121,6 +126,7 @@ const AutoResizeTextarea = forwardRef<
|
|||||||
setTimeout(setFalse, 0);
|
setTimeout(setFalse, 0);
|
||||||
}}
|
}}
|
||||||
rows={1}
|
rows={1}
|
||||||
|
disabled={!isTauri && disabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div ref={calcRef} className="absolute whitespace-nowrap -z-10">
|
<div ref={calcRef} className="absolute whitespace-nowrap -z-10">
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ const ContextMenu = ({ formatUrl }: ContextMenuProps) => {
|
|||||||
<kbd
|
<kbd
|
||||||
key={key}
|
key={key}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex justify-center items-center font-sans h-[20px] min-w-[20px] text-[10px] rounded-md border border-[#EDEDED] dark:border-white/10 bg-white dark:bg-[#202126]",
|
"flex justify-center items-center font-sans h-[20px] min-w-[20px] text-[10px] rounded-[6px] border border-[#EDEDED] dark:border-white/10 bg-white dark:bg-[#202126]",
|
||||||
{
|
{
|
||||||
"px-1": key.length > 1,
|
"px-1": key.length > 1,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { useInfiniteScroll } from "ahooks";
|
import { useInfiniteScroll } from "ahooks";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Data } from "ahooks/lib/useInfiniteScroll/types";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { isNil } from "lodash-es";
|
||||||
|
|
||||||
import { useSearchStore } from "@/stores/searchStore";
|
import { useSearchStore } from "@/stores/searchStore";
|
||||||
import { SearchHeader } from "./SearchHeader";
|
import { SearchHeader } from "./SearchHeader";
|
||||||
@@ -11,9 +14,7 @@ import { Get } from "@/api/axiosRequest";
|
|||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
import { useConnectStore } from "@/stores/connectStore";
|
import { useConnectStore } from "@/stores/connectStore";
|
||||||
import SearchEmpty from "../Common/SearchEmpty";
|
import SearchEmpty from "../Common/SearchEmpty";
|
||||||
import { Data } from "ahooks/lib/useInfiniteScroll/types";
|
import Scrollbar from "@/components/Common/Scrollbar";
|
||||||
import { nanoid } from "nanoid";
|
|
||||||
import { isNil } from "lodash-es";
|
|
||||||
|
|
||||||
interface DocumentListProps {
|
interface DocumentListProps {
|
||||||
onSelectDocument: (id: string) => void;
|
onSelectDocument: (id: string) => void;
|
||||||
@@ -129,7 +130,6 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
|||||||
// set first select hover
|
// set first select hover
|
||||||
if (from === 0 && list.length > 0) {
|
if (from === 0 && list.length > 0) {
|
||||||
setSelectedItem(0);
|
setSelectedItem(0);
|
||||||
getDocDetail(list[0]?.document);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (taskId === taskIdRef.current) {
|
if (taskId === taskIdRef.current) {
|
||||||
@@ -192,12 +192,11 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onMouseEnter = useCallback(
|
const onMouseEnter = useCallback(
|
||||||
(index: number, item: any) => {
|
(index: number) => {
|
||||||
if (isKeyboardMode) return;
|
if (isKeyboardMode) return;
|
||||||
getDocDetail(item);
|
|
||||||
setSelectedItem(index);
|
setSelectedItem(index);
|
||||||
},
|
},
|
||||||
[isKeyboardMode, getDocDetail]
|
[isKeyboardMode]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -235,7 +234,6 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
|||||||
? Math.max(0, prev - 1)
|
? Math.max(0, prev - 1)
|
||||||
: Math.min(data.list.length - 1, prev + 1);
|
: Math.min(data.list.length - 1, prev + 1);
|
||||||
|
|
||||||
getDocDetail(data.list[nextIndex]?.document);
|
|
||||||
itemRefs.current[nextIndex]?.scrollIntoView({
|
itemRefs.current[nextIndex]?.scrollIntoView({
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
block: "nearest",
|
block: "nearest",
|
||||||
@@ -283,6 +281,14 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
|||||||
};
|
};
|
||||||
}, [handleKeyDown, handleMouseMove]);
|
}, [handleKeyDown, handleMouseMove]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedItem === null) return;
|
||||||
|
const doc = data.list[selectedItem]?.document;
|
||||||
|
if (doc) {
|
||||||
|
getDocDetail(doc);
|
||||||
|
}
|
||||||
|
}, [selectedItem, data, getDocDetail]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`border-r border-gray-200 dark:border-gray-700 flex flex-col h-full overflow-x-hidden ${
|
className={`border-r border-gray-200 dark:border-gray-700 flex flex-col h-full overflow-x-hidden ${
|
||||||
@@ -297,10 +303,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<Scrollbar className="flex-1 overflow-auto pr-0.5" ref={containerRef}>
|
||||||
className="flex-1 overflow-auto custom-scrollbar pr-0.5"
|
|
||||||
ref={containerRef}
|
|
||||||
>
|
|
||||||
{data?.list && data.list.length > 0 && (
|
{data?.list && data.list.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
{data.list.map((hit, index) => (
|
{data.list.map((hit, index) => (
|
||||||
@@ -310,7 +313,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
|||||||
item={{ ...hit.document, querySource: hit.source }}
|
item={{ ...hit.document, querySource: hit.source }}
|
||||||
isSelected={selectedItem === index}
|
isSelected={selectedItem === index}
|
||||||
currentIndex={index}
|
currentIndex={index}
|
||||||
onMouseEnter={() => onMouseEnter(index, hit.document)}
|
onMouseEnter={() => onMouseEnter(index)}
|
||||||
onItemClick={() => {
|
onItemClick={() => {
|
||||||
platformAdapter.openSearchItem(hit.document, formatUrl);
|
platformAdapter.openSearchItem(hit.document, formatUrl);
|
||||||
}}
|
}}
|
||||||
@@ -334,7 +337,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
|||||||
<SearchEmpty />
|
<SearchEmpty />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Scrollbar>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
|
|||||||
import { SearchSource } from "./SearchSource";
|
import { SearchSource } from "./SearchSource";
|
||||||
import DropdownListItem from "./DropdownListItem";
|
import DropdownListItem from "./DropdownListItem";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
|
import Scrollbar from "@/components/Common/Scrollbar";
|
||||||
|
|
||||||
type ISearchData = Record<string, QueryHits[]>;
|
type ISearchData = Record<string, QueryHits[]>;
|
||||||
|
|
||||||
@@ -145,13 +146,14 @@ function DropdownList({
|
|||||||
handleItemAction,
|
handleItemAction,
|
||||||
isChatMode,
|
isChatMode,
|
||||||
formatUrl,
|
formatUrl,
|
||||||
|
searchData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Scrollbar
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className="h-full w-full p-2 flex flex-col overflow-y-auto custom-scrollbar focus:outline-none"
|
className="h-full w-full p-2 flex flex-col overflow-y-auto focus:outline-none"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
aria-label={t("search.header.results")}
|
aria-label={t("search.header.results")}
|
||||||
@@ -170,12 +172,13 @@ function DropdownList({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{items.map((hit) => {
|
{items.map((hit, idx) => {
|
||||||
const currentIndex = hit.document.index || 0;
|
const currentIndex = hit.document.index ?? 0;
|
||||||
|
const itemKey = `${sourceName}-${hit.document.id ?? currentIndex}-${idx}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownListItem
|
<DropdownListItem
|
||||||
key={hit.document.id}
|
key={itemKey}
|
||||||
item={hit.document}
|
item={hit.document}
|
||||||
selectedIndex={selectedIndex}
|
selectedIndex={selectedIndex}
|
||||||
currentIndex={currentIndex}
|
currentIndex={currentIndex}
|
||||||
@@ -188,7 +191,7 @@ function DropdownList({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Scrollbar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import clsx from "clsx";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useSearchStore } from "@/stores/searchStore";
|
import { useSearchStore } from "@/stores/searchStore";
|
||||||
import { parseSearchQuery } from "@/utils";
|
import { installExtensionError, parseSearchQuery } from "@/utils";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import SearchEmpty from "../Common/SearchEmpty";
|
import SearchEmpty from "../Common/SearchEmpty";
|
||||||
import ExtensionDetail from "./ExtensionDetail";
|
import ExtensionDetail from "./ExtensionDetail";
|
||||||
@@ -139,7 +139,7 @@ const ExtensionStore = ({ extensionId }: { extensionId?: string }) => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("search_extension", result);
|
// console.log("search_extension", result);
|
||||||
|
|
||||||
setList(result ?? []);
|
setList(result ?? []);
|
||||||
|
|
||||||
@@ -244,7 +244,7 @@ const ExtensionStore = ({ extensionId }: { extensionId?: string }) => {
|
|||||||
"info"
|
"info"
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addError(String(error), "error");
|
installExtensionError(String(error));
|
||||||
} finally {
|
} finally {
|
||||||
const { installingExtensions } = useSearchStore.getState();
|
const { installingExtensions } = useSearchStore.getState();
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,17 @@ import { useAssistantManager } from "./AssistantManager";
|
|||||||
import InputControls from "./InputControls";
|
import InputControls from "./InputControls";
|
||||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||||
import AudioRecording from "../AudioRecording";
|
import AudioRecording from "../AudioRecording";
|
||||||
import { getUploadedAttachmentsId, isDefaultServer } from "@/utils";
|
import {
|
||||||
|
canNavigateBack,
|
||||||
|
getUploadedAttachmentsId,
|
||||||
|
isDefaultServer,
|
||||||
|
visibleFilterBar,
|
||||||
|
visibleSearchBar,
|
||||||
|
} from "@/utils";
|
||||||
import { useTauriFocus } from "@/hooks/useTauriFocus";
|
import { useTauriFocus } from "@/hooks/useTauriFocus";
|
||||||
import { SendMessageParams } from "../Assistant/Chat";
|
import { SendMessageParams } from "../Assistant/Chat";
|
||||||
import { isEmpty } from "lodash-es";
|
import { isEmpty } from "lodash-es";
|
||||||
|
import { formatKey } from "@/utils/keyboardUtils";
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onSend: (params: SendMessageParams) => void;
|
onSend: (params: SendMessageParams) => void;
|
||||||
@@ -88,7 +95,7 @@ export default function ChatInput({
|
|||||||
|
|
||||||
const { currentAssistant } = useConnectStore();
|
const { currentAssistant } = useConnectStore();
|
||||||
|
|
||||||
const { sourceData, goAskAi } = useSearchStore();
|
const { goAskAi } = useSearchStore();
|
||||||
|
|
||||||
const { modifierKey, returnToInput, setModifierKeyPressed } =
|
const { modifierKey, returnToInput, setModifierKeyPressed } =
|
||||||
useShortcutsStore();
|
useShortcutsStore();
|
||||||
@@ -103,8 +110,7 @@ export default function ChatInput({
|
|||||||
const textareaRef = useRef<{ reset: () => void; focus: () => void }>(null);
|
const textareaRef = useRef<{ reset: () => void; focus: () => void }>(null);
|
||||||
|
|
||||||
const { curChatEnd } = useChatStore();
|
const { curChatEnd } = useChatStore();
|
||||||
const { setSearchValue, visibleExtensionStore, selectedExtension } =
|
const { setSearchValue } = useSearchStore();
|
||||||
useSearchStore();
|
|
||||||
const { uploadAttachments } = useChatStore();
|
const { uploadAttachments } = useChatStore();
|
||||||
|
|
||||||
useTauriFocus({
|
useTauriFocus({
|
||||||
@@ -121,8 +127,6 @@ export default function ChatInput({
|
|||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
const trimmedValue = inputValue.trim();
|
const trimmedValue = inputValue.trim();
|
||||||
|
|
||||||
console.log("handleSubmit", trimmedValue, disabled);
|
|
||||||
|
|
||||||
if ((trimmedValue || !isEmpty(uploadAttachments)) && !disabled) {
|
if ((trimmedValue || !isEmpty(uploadAttachments)) && !disabled) {
|
||||||
changeInput("");
|
changeInput("");
|
||||||
onSend({
|
onSend({
|
||||||
@@ -237,46 +241,19 @@ export default function ChatInput({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isChatMode &&
|
|
||||||
(sourceData || visibleExtensionStore || selectedExtension) && (
|
|
||||||
<div
|
|
||||||
className={`absolute ${
|
|
||||||
lineCount === 1 ? "-top-[5px]" : "top-[calc(100%-25px)]"
|
|
||||||
} left-2`}
|
|
||||||
>
|
|
||||||
<VisibleKey shortcut="←" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/*
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
`absolute ${
|
|
||||||
lineCount === 1 ? "-top-[5px]" : "top-[calc(100%-25px)]"
|
|
||||||
} left-2`,
|
|
||||||
{
|
|
||||||
"left-8": !isChatMode && sourceData,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<VisibleKey shortcut={returnToInput} />
|
|
||||||
</div>
|
|
||||||
*/}
|
|
||||||
|
|
||||||
{!isChatMode &&
|
{!isChatMode &&
|
||||||
isTauri &&
|
isTauri &&
|
||||||
!goAskAi &&
|
|
||||||
askAI &&
|
askAI &&
|
||||||
!disabledExtensions.includes("QuickAIAccess") &&
|
!disabledExtensions.includes("QuickAIAccess") &&
|
||||||
!visibleExtensionStore && (
|
!canNavigateBack() && (
|
||||||
<div className="flex items-center gap-2 text-sm text-[#AEAEAE] dark:text-[#545454] whitespace-nowrap">
|
<div className="flex items-center gap-2 text-sm text-[#AEAEAE] dark:text-[#545454] whitespace-nowrap">
|
||||||
<span>
|
<span>
|
||||||
{t("search.askCocoAi.title", {
|
{t("search.askCocoAi.title", {
|
||||||
replace: [akiAiTooltipPrefix, askAI.name],
|
replace: [akiAiTooltipPrefix, askAI.name],
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center justify-center w-8 h-[20px] text-xs rounded-md border border-black/10 dark:border-[#545454]">
|
<div className="flex items-center justify-center px-1 h-[20px] text-xs rounded-[6px] border border-black/10 dark:border-[#545454]">
|
||||||
Tab
|
{formatKey(modifierKey)} + {formatKey("Enter")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -330,53 +307,60 @@ export default function ChatInput({
|
|||||||
return (
|
return (
|
||||||
<div className={`w-full relative`}>
|
<div className={`w-full relative`}>
|
||||||
<div
|
<div
|
||||||
className={`p-2 flex items-center dark:text-[#D8D8D8] bg-[#ededed] dark:bg-[#202126] rounded-md transition-all relative overflow-hidden`}
|
ref={containerRef}
|
||||||
|
className={`flex items-center dark:text-[#D8D8D8] rounded-[6px] transition-all relative overflow-hidden`}
|
||||||
>
|
>
|
||||||
<div
|
{lineCount === 1 && renderSearchIcon()}
|
||||||
ref={containerRef}
|
|
||||||
className={clsx("relative w-full", {
|
|
||||||
"flex items-center gap-2": lineCount === 1,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{lineCount === 1 && renderSearchIcon()}
|
|
||||||
|
|
||||||
{renderTextarea()}
|
{visibleSearchBar() && (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"relative w-full p-2 bg-[#ededed] dark:bg-[#202126]",
|
||||||
|
{
|
||||||
|
"flex items-center gap-2": lineCount === 1,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderTextarea()}
|
||||||
|
|
||||||
{lineCount === 1 && renderExtraIcon()}
|
{lineCount === 1 && renderExtraIcon()}
|
||||||
|
|
||||||
{lineCount > 1 && (
|
{lineCount > 1 && (
|
||||||
<div className="flex items-center mt-2">
|
<div className="flex items-center mt-2">
|
||||||
<div className="flex-1">{renderSearchIcon()}</div>
|
<div className="flex-1">{renderSearchIcon()}</div>
|
||||||
|
|
||||||
<div className="self-end">{renderExtraIcon()}</div>
|
<div className="self-end">{renderExtraIcon()}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InputControls
|
{visibleFilterBar() && (
|
||||||
isChatMode={isChatMode}
|
<InputControls
|
||||||
isChatPage={isChatPage}
|
isChatMode={isChatMode}
|
||||||
hasModules={hasModules}
|
isChatPage={isChatPage}
|
||||||
searchPlaceholder={searchPlaceholder}
|
hasModules={hasModules}
|
||||||
chatPlaceholder={chatPlaceholder}
|
searchPlaceholder={searchPlaceholder}
|
||||||
isSearchActive={isSearchActive}
|
chatPlaceholder={chatPlaceholder}
|
||||||
setIsSearchActive={setIsSearchActive}
|
isSearchActive={isSearchActive}
|
||||||
isDeepThinkActive={isDeepThinkActive}
|
setIsSearchActive={setIsSearchActive}
|
||||||
setIsDeepThinkActive={setIsDeepThinkActive}
|
isDeepThinkActive={isDeepThinkActive}
|
||||||
isMCPActive={isMCPActive}
|
setIsDeepThinkActive={setIsDeepThinkActive}
|
||||||
setIsMCPActive={setIsMCPActive}
|
isMCPActive={isMCPActive}
|
||||||
changeMode={changeMode}
|
setIsMCPActive={setIsMCPActive}
|
||||||
checkScreenPermission={checkScreenPermission}
|
changeMode={changeMode}
|
||||||
requestScreenPermission={requestScreenPermission}
|
checkScreenPermission={checkScreenPermission}
|
||||||
getScreenMonitors={getScreenMonitors}
|
requestScreenPermission={requestScreenPermission}
|
||||||
getScreenWindows={getScreenWindows}
|
getScreenMonitors={getScreenMonitors}
|
||||||
captureMonitorScreenshot={captureMonitorScreenshot}
|
getScreenWindows={getScreenWindows}
|
||||||
captureWindowScreenshot={captureWindowScreenshot}
|
captureMonitorScreenshot={captureMonitorScreenshot}
|
||||||
openFileDialog={openFileDialog}
|
captureWindowScreenshot={captureWindowScreenshot}
|
||||||
getFileMetadata={getFileMetadata}
|
openFileDialog={openFileDialog}
|
||||||
getFileIcon={getFileIcon}
|
getFileMetadata={getFileMetadata}
|
||||||
/>
|
getFileIcon={getFileIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import SearchPopover from "./SearchPopover";
|
import SearchPopover from "./SearchPopover";
|
||||||
import MCPPopover from "./MCPPopover";
|
import MCPPopover from "./MCPPopover";
|
||||||
import ChatSwitch from "@/components/Common/ChatSwitch";
|
import ChatSwitch from "@/components/Common/ChatSwitch";
|
||||||
import Copyright from "@/components/Common/Copyright";
|
|
||||||
import type { DataSource } from "@/types/commands";
|
import type { DataSource } from "@/types/commands";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import { useConnectStore } from "@/stores/connectStore";
|
import { useConnectStore } from "@/stores/connectStore";
|
||||||
@@ -17,6 +16,7 @@ import { useSearchStore } from "@/stores/searchStore";
|
|||||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||||
import { parseSearchQuery, SearchQuery } from "@/utils";
|
import { parseSearchQuery, SearchQuery } from "@/utils";
|
||||||
import InputUpload from "./InputUpload";
|
import InputUpload from "./InputUpload";
|
||||||
|
import Copyright from "../Common/Copyright";
|
||||||
|
|
||||||
interface InputControlsProps {
|
interface InputControlsProps {
|
||||||
isChatMode: boolean;
|
isChatMode: boolean;
|
||||||
@@ -187,7 +187,7 @@ const InputControls = ({
|
|||||||
{source?.type === "deep_think" && source?.config?.visible && (
|
{source?.type === "deep_think" && source?.config?.visible && (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center justify-center gap-1 h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]",
|
"flex items-center justify-center gap-1 h-[20px] px-1 rounded-[6px] transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]",
|
||||||
{
|
{
|
||||||
"!bg-[rgba(0,114,255,0.3)]": isDeepThinkActive,
|
"!bg-[rgba(0,114,255,0.3)]": isDeepThinkActive,
|
||||||
}
|
}
|
||||||
@@ -250,7 +250,7 @@ const InputControls = ({
|
|||||||
!visibleExtensionStore && (
|
!visibleExtensionStore && (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"inline-flex items-center gap-1 px-2 py-1 rounded-full hover:!text-[#881c94] cursor-pointer transition",
|
"inline-flex items-center gap-1 h-[20px] px-1 rounded-full hover:!text-[#881c94] cursor-pointer transition",
|
||||||
[
|
[
|
||||||
enabledAiOverview
|
enabledAiOverview
|
||||||
? "text-[#881c94]"
|
? "text-[#881c94]"
|
||||||
@@ -270,7 +270,7 @@ const InputControls = ({
|
|||||||
setEnabledAiOverview(!enabledAiOverview);
|
setEnabledAiOverview(!enabledAiOverview);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Sparkles className="size-4" />
|
<Sparkles className="size-3" />
|
||||||
</VisibleKey>
|
</VisibleKey>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ const InputUpload: FC<InputUploadProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu>
|
<Menu>
|
||||||
<MenuButton className="flex items-center justify-center h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
|
<MenuButton className="flex items-center justify-center h-[20px] px-1 rounded-[6px] transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={t("search.input.uploadFileHints.tooltip", {
|
content={t("search.input.uploadFileHints.tooltip", {
|
||||||
replace: [
|
replace: [
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export default function MCPPopover({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex justify-center items-center gap-1 h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
|
"flex justify-center items-center gap-1 h-[20px] px-1 rounded-[6px] transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
|
||||||
{
|
{
|
||||||
"!bg-[rgba(0,114,255,0.3)]": isMCPActive,
|
"!bg-[rgba(0,114,255,0.3)]": isMCPActive,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import AskAi from "./AskAi";
|
|||||||
import { useSearch } from "@/hooks/useSearch";
|
import { useSearch } from "@/hooks/useSearch";
|
||||||
import ExtensionStore from "./ExtensionStore";
|
import ExtensionStore from "./ExtensionStore";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import ViewExtension from "./ViewExtension"
|
import ViewExtension from "./ViewExtension";
|
||||||
|
import { visibleFooterBar } from "@/utils";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
const SearchResultsPanel = memo<{
|
const SearchResultsPanel = memo<{
|
||||||
input: string;
|
input: string;
|
||||||
@@ -47,8 +49,12 @@ const SearchResultsPanel = memo<{
|
|||||||
}
|
}
|
||||||
}, [input, isChatMode, performSearch, sourceData]);
|
}, [input, isChatMode, performSearch, sourceData]);
|
||||||
|
|
||||||
const { setSelectedAssistant, selectedSearchContent, visibleExtensionStore, viewExtensionOpened } =
|
const {
|
||||||
useSearchStore();
|
setSelectedAssistant,
|
||||||
|
selectedSearchContent,
|
||||||
|
visibleExtensionStore,
|
||||||
|
viewExtensionOpened,
|
||||||
|
} = useSearchStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedSearchContent?.type === "AI Assistant") {
|
if (selectedSearchContent?.type === "AI Assistant") {
|
||||||
@@ -164,7 +170,12 @@ function Search({
|
|||||||
const mainWindowRef = useRef<HTMLDivElement>(null);
|
const mainWindowRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={mainWindowRef} className={`h-full pb-8 w-full relative`}>
|
<div
|
||||||
|
ref={mainWindowRef}
|
||||||
|
className={clsx("h-full w-full relative", {
|
||||||
|
"pb-8": visibleFooterBar(),
|
||||||
|
})}
|
||||||
|
>
|
||||||
<SearchResultsPanel
|
<SearchResultsPanel
|
||||||
input={input}
|
input={input}
|
||||||
isChatMode={isChatMode}
|
isChatMode={isChatMode}
|
||||||
@@ -173,7 +184,7 @@ function Search({
|
|||||||
formatUrl={formatUrl}
|
formatUrl={formatUrl}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Footer setIsPinnedWeb={setIsPinned} />
|
{visibleFooterBar() && <Footer setIsPinnedWeb={setIsPinned} />}
|
||||||
|
|
||||||
<ContextMenu formatUrl={formatUrl} />
|
<ContextMenu formatUrl={formatUrl} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,61 @@
|
|||||||
import { useSearchStore } from "@/stores/searchStore";
|
import { useSearchStore } from "@/stores/searchStore";
|
||||||
import { ArrowBigLeft, Search, X } from "lucide-react";
|
import { ChevronLeft, Search } from "lucide-react";
|
||||||
|
|
||||||
import FontIcon from "@/components/Common/Icons/FontIcon";
|
import FontIcon from "@/components/Common/Icons/FontIcon";
|
||||||
|
import { FC } from "react";
|
||||||
|
import lightDefaultIcon from "@/assets/images/source_default.png";
|
||||||
|
import darkDefaultIcon from "@/assets/images/source_default_dark.png";
|
||||||
|
import { useThemeStore } from "@/stores/themeStore";
|
||||||
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
|
import { navigateBack, visibleSearchBar } from "@/utils";
|
||||||
|
import VisibleKey from "../Common/VisibleKey";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
interface MultilevelWrapperProps {
|
||||||
|
title?: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MultilevelWrapper: FC<MultilevelWrapperProps> = (props) => {
|
||||||
|
const { icon, title = "" } = props;
|
||||||
|
const { isDark } = useThemeStore();
|
||||||
|
|
||||||
|
const renderIcon = () => {
|
||||||
|
if (!icon) {
|
||||||
|
return <img src={isDark ? darkDefaultIcon : lightDefaultIcon} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (icon.startsWith("font_")) {
|
||||||
|
return <FontIcon name={icon} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <img src={icon} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center h-[40px] gap-1 px-2 border border-[#EDEDED] dark:border-[#202126] rounded-l-lg",
|
||||||
|
{
|
||||||
|
"justify-center": visibleSearchBar(),
|
||||||
|
"w-[calc(100vw-16px)] rounded-r-lg": !visibleSearchBar(),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<VisibleKey shortcut="backspace" onKeyPress={navigateBack}>
|
||||||
|
<ChevronLeft
|
||||||
|
className="size-5 text-[#ccc] dark:text-[#d8d8d8] cursor-pointer"
|
||||||
|
onClick={navigateBack}
|
||||||
|
/>
|
||||||
|
</VisibleKey>
|
||||||
|
|
||||||
|
<div className="size-5 [&>*]:size-full">{renderIcon()}</div>
|
||||||
|
|
||||||
|
<span className="text-sm whitespace-nowrap">{title}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface SearchIconsProps {
|
interface SearchIconsProps {
|
||||||
lineCount: number;
|
lineCount: number;
|
||||||
@@ -16,13 +70,11 @@ export default function SearchIcons({
|
|||||||
}: SearchIconsProps) {
|
}: SearchIconsProps) {
|
||||||
const {
|
const {
|
||||||
sourceData,
|
sourceData,
|
||||||
setSourceData,
|
|
||||||
goAskAi,
|
goAskAi,
|
||||||
setGoAskAi,
|
|
||||||
visibleExtensionStore,
|
visibleExtensionStore,
|
||||||
setVisibleExtensionStore,
|
|
||||||
visibleExtensionDetail,
|
visibleExtensionDetail,
|
||||||
setVisibleExtensionDetail,
|
selectedExtension,
|
||||||
|
viewExtensionOpened,
|
||||||
} = useSearchStore();
|
} = useSearchStore();
|
||||||
|
|
||||||
if (isChatMode) {
|
if (isChatMode) {
|
||||||
@@ -30,54 +82,43 @@ export default function SearchIcons({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
|
if (visibleExtensionStore) {
|
||||||
|
if (visibleExtensionDetail && selectedExtension) {
|
||||||
|
const { name, icon } = selectedExtension;
|
||||||
|
|
||||||
|
return <MultilevelWrapper title={name} icon={icon} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MultilevelWrapper title="Extensions Store" icon="font_Store" />;
|
||||||
|
}
|
||||||
|
|
||||||
if (goAskAi && assistant) {
|
if (goAskAi && assistant) {
|
||||||
return (
|
const { name, icon } = assistant;
|
||||||
<div className="flex h-8 -my-1 -mx-1">
|
|
||||||
<div className="flex items-center gap-2 pl-2 text-sm bg-white dark:bg-black rounded-l-sm">
|
|
||||||
<div className="flex items-center gap-1 text-[#333] dark:text-[#D8D8D8]">
|
|
||||||
{assistant.icon?.startsWith("font_") ? (
|
|
||||||
<FontIcon name={assistant.icon} className="size-5" />
|
|
||||||
) : (
|
|
||||||
<img src={assistant.icon} className="size-5" />
|
|
||||||
)}
|
|
||||||
<span>{assistant.name}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<X
|
return <MultilevelWrapper title={name} icon={icon} />;
|
||||||
className="size-4 text-[#999] cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
setGoAskAi(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative w-4 overflow-hidden">
|
|
||||||
<div className="absolute size-0 border-[16px] border-transparent border-l-white dark:border-l-black"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sourceData || visibleExtensionStore || visibleExtensionDetail) {
|
if (sourceData) {
|
||||||
return (
|
const { source } = sourceData;
|
||||||
<ArrowBigLeft
|
const { name, icon } = source;
|
||||||
className="w-4 h-4 text-[#ccc] dark:text-[#d8d8d8] cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
if (visibleExtensionDetail) {
|
|
||||||
return setVisibleExtensionDetail(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visibleExtensionStore) {
|
return <MultilevelWrapper title={name} icon={icon} />;
|
||||||
return setVisibleExtensionStore(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
setSourceData(void 0);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Search className="w-4 h-4 text-[#ccc] dark:text-[#d8d8d8]" />;
|
if (viewExtensionOpened) {
|
||||||
|
const name = viewExtensionOpened[0];
|
||||||
|
const icon = viewExtensionOpened[1];
|
||||||
|
|
||||||
|
const iconPath = icon ? platformAdapter.convertFileSrc(icon) : void 0;
|
||||||
|
|
||||||
|
return <MultilevelWrapper title={name} icon={iconPath} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center pl-2 h-[40px] bg-[#ededed] dark:bg-[#202126]">
|
||||||
|
<Search className="w-4 h-4 text-[#ccc] dark:text-[#d8d8d8]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (lineCount === 1) {
|
if (lineCount === 1) {
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export default function SearchPopover({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex justify-center items-center gap-1 h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
|
"flex justify-center items-center gap-1 h-[20px] px-1 rounded-[6px] transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
|
||||||
{
|
{
|
||||||
"!bg-[rgba(0,114,255,0.3)]": isSearchActive,
|
"!bg-[rgba(0,114,255,0.3)]": isSearchActive,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ export const SearchSource: React.FC<SearchSourceProps> = ({
|
|||||||
defaultIcon={isDark ? source_default_dark_img : source_default_img}
|
defaultIcon={isDark ? source_default_dark_img : source_default_img}
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
/>
|
/>
|
||||||
{sourceName} {isTauri && items[0]?.source?.name && `- ${items[0].source.name}`}
|
{sourceName}{" "}
|
||||||
|
{isTauri && items[0]?.source?.name && `- ${items[0].source.name}`}
|
||||||
<div className="flex-1 border-b border-b-[#e6e6e6] dark:border-b-[#272626]"></div>
|
<div className="flex-1 border-b border-b-[#e6e6e6] dark:border-b-[#272626]"></div>
|
||||||
{!hideArrow && (
|
{!hideArrow && (
|
||||||
<>
|
<>
|
||||||
@@ -56,7 +57,7 @@ export const SearchSource: React.FC<SearchSourceProps> = ({
|
|||||||
</IconWrapper>
|
</IconWrapper>
|
||||||
{showIndex && sourceName === selectedName && (
|
{showIndex && sourceName === selectedName && (
|
||||||
<div className="absolute top-1 right-4">
|
<div className="absolute top-1 right-4">
|
||||||
<VisibleKey shortcut="→" />
|
<VisibleKey shortcut="Tab" shortcutClassName="w-8" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
/*
|
|
||||||
* ViewExtension.tsx
|
|
||||||
*
|
|
||||||
* View that will be rendered when opening a View extension.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { ArrowLeft } from "lucide-react";
|
|
||||||
import { useSearchStore } from "@/stores/searchStore";
|
import { useSearchStore } from "@/stores/searchStore";
|
||||||
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
|
import {
|
||||||
import { ExtensionFileSystemPermission, FileSystemAccess } from "../Settings/Extensions";
|
ExtensionFileSystemPermission,
|
||||||
|
FileSystemAccess,
|
||||||
|
} from "../Settings/Extensions";
|
||||||
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
|
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||||
|
|
||||||
const ViewExtension: React.FC = () => {
|
const ViewExtension: React.FC = () => {
|
||||||
const { setViewExtensionOpened, viewExtensionOpened } = useSearchStore();
|
const { viewExtensionOpened } = useSearchStore();
|
||||||
const [pagePath, setPagePath] = useState<string>("");
|
|
||||||
// Complete list of the backend APIs, grouped by their category.
|
// Complete list of the backend APIs, grouped by their category.
|
||||||
const [apis, setApis] = useState<Map<string, string[]> | null>(null);
|
const [apis, setApis] = useState<Map<string, string[]> | null>(null);
|
||||||
|
const { setModifierKeyPressed } = useShortcutsStore();
|
||||||
|
|
||||||
if (viewExtensionOpened == null) {
|
if (viewExtensionOpened == null) {
|
||||||
// When this view gets loaded, this state should not be NULL.
|
// When this view gets loaded, this state should not be NULL.
|
||||||
@@ -25,25 +22,15 @@ const ViewExtension: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tauri/webview is not allowed to access local files directly,
|
|
||||||
// use convertFileSrc to work around the issue.
|
|
||||||
useEffect(() => {
|
|
||||||
const setupFileUrl = async () => {
|
|
||||||
// The check above ensures viewExtensionOpened is not null here.
|
|
||||||
const filePath = viewExtensionOpened[0];
|
|
||||||
if (filePath) {
|
|
||||||
setPagePath(convertFileSrc(filePath));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setupFileUrl();
|
|
||||||
}, [viewExtensionOpened]);
|
|
||||||
|
|
||||||
// invoke `apis()` and set the state
|
// invoke `apis()` and set the state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setModifierKeyPressed(false);
|
||||||
|
|
||||||
const fetchApis = async () => {
|
const fetchApis = async () => {
|
||||||
try {
|
try {
|
||||||
const availableApis = await invoke("apis") as Record<string, string[]>;
|
const availableApis = (await platformAdapter.invokeBackend(
|
||||||
|
"apis"
|
||||||
|
)) as Record<string, string[]>;
|
||||||
setApis(new Map(Object.entries(availableApis)));
|
setApis(new Map(Object.entries(availableApis)));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch APIs:", error);
|
console.error("Failed to fetch APIs:", error);
|
||||||
@@ -53,12 +40,8 @@ const ViewExtension: React.FC = () => {
|
|||||||
fetchApis();
|
fetchApis();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
setViewExtensionOpened(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// White list of the permission entries
|
// White list of the permission entries
|
||||||
const permission = viewExtensionOpened[1];
|
const permission = viewExtensionOpened[3];
|
||||||
|
|
||||||
// apis is in format {"category": ["api1", "api2"]}, to make the permission check
|
// apis is in format {"category": ["api1", "api2"]}, to make the permission check
|
||||||
// easier, reverse the map key values: {"api1": "category", "api2": "category"}
|
// easier, reverse the map key values: {"api1": "category", "api2": "category"}
|
||||||
@@ -108,10 +91,10 @@ const ViewExtension: React.FC = () => {
|
|||||||
const category = reversedApis.get(command)!;
|
const category = reversedApis.get(command)!;
|
||||||
var api = null;
|
var api = null;
|
||||||
if (permission == null) {
|
if (permission == null) {
|
||||||
api = null
|
api = null;
|
||||||
} else {
|
} else {
|
||||||
api = permission.api
|
api = permission.api;
|
||||||
};
|
}
|
||||||
if (!apiPermissionCheck(category, command, api)) {
|
if (!apiPermissionCheck(category, command, api)) {
|
||||||
source.postMessage(
|
source.postMessage(
|
||||||
{
|
{
|
||||||
@@ -126,10 +109,10 @@ const ViewExtension: React.FC = () => {
|
|||||||
|
|
||||||
var fs = null;
|
var fs = null;
|
||||||
if (permission == null) {
|
if (permission == null) {
|
||||||
fs = null
|
fs = null;
|
||||||
} else {
|
} else {
|
||||||
fs = permission.fs
|
fs = permission.fs;
|
||||||
};
|
}
|
||||||
if (!(await fsPermissionCheck(command, event.data, fs))) {
|
if (!(await fsPermissionCheck(command, event.data, fs))) {
|
||||||
source.postMessage(
|
source.postMessage(
|
||||||
{
|
{
|
||||||
@@ -145,7 +128,12 @@ const ViewExtension: React.FC = () => {
|
|||||||
if (command === "read_dir") {
|
if (command === "read_dir") {
|
||||||
const { path } = event.data;
|
const { path } = event.data;
|
||||||
try {
|
try {
|
||||||
const fileNames: [String] = await invoke("read_dir", { path: path });
|
const fileNames: [String] = await platformAdapter.invokeBackend(
|
||||||
|
"read_dir",
|
||||||
|
{
|
||||||
|
path: path,
|
||||||
|
}
|
||||||
|
);
|
||||||
source.postMessage(
|
source.postMessage(
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
@@ -171,77 +159,77 @@ const ViewExtension: React.FC = () => {
|
|||||||
console.info("Coco extension API listener is up");
|
console.info("Coco extension API listener is up");
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('message', messageHandler);
|
window.removeEventListener("message", messageHandler);
|
||||||
};
|
};
|
||||||
}, [reversedApis, permission]); // Add apiPermissions as dependency
|
}, [reversedApis, permission]); // Add apiPermissions as dependency
|
||||||
|
|
||||||
return (
|
const fileUrl = viewExtensionOpened[2];
|
||||||
<div className="h-full w-full flex flex-col">
|
|
||||||
{/* Header with back button */}
|
|
||||||
<div className="flex items-center p-4 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<button
|
|
||||||
onClick={handleBack}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-gray-600 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
<ArrowLeft size={20} />
|
|
||||||
<span>Back to Search</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main content */}
|
return (
|
||||||
<div className="flex-1">
|
<iframe
|
||||||
<iframe
|
src={fileUrl}
|
||||||
src={pagePath}
|
className="w-full h-full border-0"
|
||||||
className="w-full h-full border-0"
|
onLoad={(event) => {
|
||||||
>
|
event.currentTarget.focus();
|
||||||
</iframe>
|
}}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ViewExtension;
|
export default ViewExtension;
|
||||||
|
|
||||||
|
|
||||||
// Permission check function - TypeScript translation of Rust function
|
// Permission check function - TypeScript translation of Rust function
|
||||||
const apiPermissionCheck = (category: string, api: string, allowedApis: string[] | null): boolean => {
|
const apiPermissionCheck = (
|
||||||
|
category: string,
|
||||||
|
api: string,
|
||||||
|
allowedApis: string[] | null
|
||||||
|
): boolean => {
|
||||||
if (!allowedApis) {
|
if (!allowedApis) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const qualifiedApi = `${category}:${api}`;
|
const qualifiedApi = `${category}:${api}`;
|
||||||
return allowedApis.some(a => a === qualifiedApi);
|
return allowedApis.some((a) => a === qualifiedApi);
|
||||||
};
|
};
|
||||||
|
|
||||||
const extractFsAccessPattern = (command: string, requestPayload: any): [string, FileSystemAccess] => {
|
const extractFsAccessPattern = (
|
||||||
|
command: string,
|
||||||
|
requestPayload: any
|
||||||
|
): [string, FileSystemAccess] => {
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case "read_dir": {
|
case "read_dir": {
|
||||||
const { path } = requestPayload;
|
const { path } = requestPayload;
|
||||||
|
|
||||||
return [path, ["read"]];
|
return [path, ["read"]];
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
throw new Error(`unknown command ${command}`);
|
throw new Error(`unknown command ${command}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const fsPermissionCheck = async (command: string, requestPayload: any, fsPermission: ExtensionFileSystemPermission[] | null): Promise<boolean> => {
|
const fsPermissionCheck = async (
|
||||||
|
command: string,
|
||||||
|
requestPayload: any,
|
||||||
|
fsPermission: ExtensionFileSystemPermission[] | null
|
||||||
|
): Promise<boolean> => {
|
||||||
if (!fsPermission) {
|
if (!fsPermission) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [ path, access ] = extractFsAccessPattern(command, requestPayload);
|
const [path, access] = extractFsAccessPattern(command, requestPayload);
|
||||||
const clean_path = await invoke("path_absolute", { path: path });
|
const clean_path = await platformAdapter.invokeBackend("path_absolute", {
|
||||||
|
path: path,
|
||||||
|
});
|
||||||
|
|
||||||
// Walk through fsPermission array to find matching paths
|
// Walk through fsPermission array to find matching paths
|
||||||
for (const permission of fsPermission) {
|
for (const permission of fsPermission) {
|
||||||
if (permission.path === clean_path) {
|
if (permission.path === clean_path) {
|
||||||
// Check if all required access permissions are included in the permission's access array
|
// Check if all required access permissions are included in the permission's access array
|
||||||
const hasAllRequiredAccess = access.every(requiredAccess =>
|
const hasAllRequiredAccess = access.every((requiredAccess) =>
|
||||||
permission.access.includes(requiredAccess)
|
permission.access.includes(requiredAccess)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasAllRequiredAccess) {
|
if (hasAllRequiredAccess) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -249,4 +237,4 @@ const fsPermissionCheck = async (command: string, requestPayload: any, fsPermiss
|
|||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
} from "react";
|
} from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useMount } from "ahooks";
|
import { useMount, useMutationObserver } from "ahooks";
|
||||||
|
|
||||||
import Search from "@/components/Search/Search";
|
import Search from "@/components/Search/Search";
|
||||||
import InputBox from "@/components/Search/InputBox";
|
import InputBox from "@/components/Search/InputBox";
|
||||||
@@ -21,14 +21,22 @@ import { isLinux, isWin } from "@/utils/platform";
|
|||||||
import { appReducer, initialAppState } from "@/reducers/appReducer";
|
import { appReducer, initialAppState } from "@/reducers/appReducer";
|
||||||
import { useWindowEvents } from "@/hooks/useWindowEvents";
|
import { useWindowEvents } from "@/hooks/useWindowEvents";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
import { useSearchStore } from "@/stores/searchStore";
|
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import { useStartupStore } from "@/stores/startupStore";
|
import { useStartupStore } from "@/stores/startupStore";
|
||||||
import { useThemeStore } from "@/stores/themeStore";
|
import { useThemeStore } from "@/stores/themeStore";
|
||||||
import { useConnectStore } from "@/stores/connectStore";
|
import { useConnectStore } from "@/stores/connectStore";
|
||||||
import { useAppearanceStore } from "@/stores/appearanceStore";
|
import { useAppearanceStore } from "@/stores/appearanceStore";
|
||||||
import type { StartPage } from "@/types/chat";
|
import type { StartPage } from "@/types/chat";
|
||||||
import { hasUploadingAttachment } from "@/utils";
|
import {
|
||||||
|
canNavigateBack,
|
||||||
|
hasUploadingAttachment,
|
||||||
|
visibleFilterBar,
|
||||||
|
visibleSearchBar,
|
||||||
|
} from "@/utils";
|
||||||
|
import { useTauriFocus } from "@/hooks/useTauriFocus";
|
||||||
|
import { POPOVER_PANEL_SELECTOR } from "@/constants";
|
||||||
|
import { useChatStore } from "@/stores/chatStore";
|
||||||
|
import { debounce } from "lodash-es";
|
||||||
|
|
||||||
interface SearchChatProps {
|
interface SearchChatProps {
|
||||||
isTauri?: boolean;
|
isTauri?: boolean;
|
||||||
@@ -79,6 +87,7 @@ function SearchChat({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [state, dispatch] = useReducer(appReducer, customInitialState);
|
const [state, dispatch] = useReducer(appReducer, customInitialState);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isChatMode,
|
isChatMode,
|
||||||
input,
|
input,
|
||||||
@@ -88,6 +97,78 @@ function SearchChat({
|
|||||||
isMCPActive,
|
isMCPActive,
|
||||||
isTyping,
|
isTyping,
|
||||||
} = state;
|
} = state;
|
||||||
|
|
||||||
|
const inputRef = useRef<string>();
|
||||||
|
const isChatModeRef = useRef(false);
|
||||||
|
const [hideMiddleBorder, setHideMiddleBorder] = useState(false);
|
||||||
|
|
||||||
|
const setSuppressErrors = useAppStore((state) => state.setSuppressErrors);
|
||||||
|
let collapseWindowTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
const setWindowSize = useCallback(() => {
|
||||||
|
if (collapseWindowTimer.current) {
|
||||||
|
clearTimeout(collapseWindowTimer.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = 680;
|
||||||
|
let height = 590;
|
||||||
|
|
||||||
|
const updateAppDialog = document.querySelector("#update-app-dialog");
|
||||||
|
const popoverPanelEl = document.querySelector(POPOVER_PANEL_SELECTOR);
|
||||||
|
|
||||||
|
const { hasActiveChat } = useChatStore.getState();
|
||||||
|
|
||||||
|
if (
|
||||||
|
updateAppDialog ||
|
||||||
|
canNavigateBack() ||
|
||||||
|
inputRef.current ||
|
||||||
|
popoverPanelEl ||
|
||||||
|
(isChatModeRef.current && hasActiveChat)
|
||||||
|
) {
|
||||||
|
setHideMiddleBorder(false);
|
||||||
|
setSuppressErrors(false);
|
||||||
|
} else {
|
||||||
|
const { windowMode } = useAppearanceStore.getState();
|
||||||
|
|
||||||
|
if (windowMode === "compact") {
|
||||||
|
height = 84;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (height < 590) {
|
||||||
|
const { compactModeAutoCollapseDelay } = useConnectStore.getState();
|
||||||
|
|
||||||
|
console.log("compactModeAutoCollapseDelay", compactModeAutoCollapseDelay);
|
||||||
|
|
||||||
|
collapseWindowTimer.current = setTimeout(() => {
|
||||||
|
setHideMiddleBorder(true);
|
||||||
|
setSuppressErrors(true);
|
||||||
|
|
||||||
|
platformAdapter.setWindowSize(width, height);
|
||||||
|
}, compactModeAutoCollapseDelay * 1000);
|
||||||
|
} else {
|
||||||
|
platformAdapter.setWindowSize(width, height);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const debouncedSetWindowSize = debounce(setWindowSize, 50);
|
||||||
|
|
||||||
|
useMutationObserver(debouncedSetWindowSize, document.body, {
|
||||||
|
subtree: true,
|
||||||
|
childList: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current = input;
|
||||||
|
isChatModeRef.current = isChatMode;
|
||||||
|
|
||||||
|
debouncedSetWindowSize();
|
||||||
|
}, [input, isChatMode]);
|
||||||
|
|
||||||
|
useTauriFocus({
|
||||||
|
onFocus: debouncedSetWindowSize,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "SET_SEARCH_ACTIVE",
|
type: "SET_SEARCH_ACTIVE",
|
||||||
@@ -105,18 +186,12 @@ function SearchChat({
|
|||||||
|
|
||||||
const [isWin10, setIsWin10] = useState(false);
|
const [isWin10, setIsWin10] = useState(false);
|
||||||
const blurred = useAppStore((state) => state.blurred);
|
const blurred = useAppStore((state) => state.blurred);
|
||||||
const { viewExtensionOpened } = useSearchStore();
|
|
||||||
|
|
||||||
useWindowEvents();
|
useWindowEvents();
|
||||||
|
|
||||||
const setTheme = useThemeStore((state) => state.setTheme);
|
const setTheme = useThemeStore((state) => state.setTheme);
|
||||||
const setIsDark = useThemeStore((state) => state.setIsDark);
|
const setIsDark = useThemeStore((state) => state.setIsDark);
|
||||||
|
|
||||||
const isChatModeRef = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
isChatModeRef.current = isChatMode;
|
|
||||||
}, [isChatMode]);
|
|
||||||
|
|
||||||
useMount(async () => {
|
useMount(async () => {
|
||||||
const isWin10 = await platformAdapter.isWindows10();
|
const isWin10 = await platformAdapter.isWindows10();
|
||||||
|
|
||||||
@@ -153,6 +228,8 @@ function SearchChat({
|
|||||||
|
|
||||||
dispatch({ type: "SET_INPUT", payload: params?.message ?? "" });
|
dispatch({ type: "SET_INPUT", payload: params?.message ?? "" });
|
||||||
if (isChatMode) {
|
if (isChatMode) {
|
||||||
|
const { setHasActiveChat } = useChatStore.getState();
|
||||||
|
setHasActiveChat(true);
|
||||||
chatAIRef.current?.init(params);
|
chatAIRef.current?.init(params);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -231,7 +308,7 @@ function SearchChat({
|
|||||||
return state.defaultStartupWindow;
|
return state.defaultStartupWindow;
|
||||||
});
|
});
|
||||||
|
|
||||||
const opacity = useAppearanceStore((state) => state.opacity);
|
const { normalOpacity, blurOpacity } = useAppearanceStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isTauri) {
|
if (isTauri) {
|
||||||
@@ -249,11 +326,11 @@ function SearchChat({
|
|||||||
<div
|
<div
|
||||||
data-tauri-drag-region={isTauri}
|
data-tauri-drag-region={isTauri}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"m-auto overflow-hidden relative bg-no-repeat bg-cover bg-center bg-white dark:bg-black flex flex-col",
|
"m-auto overflow-hidden relative bg-no-repeat bg-white dark:bg-black flex flex-col",
|
||||||
[
|
[
|
||||||
isTransitioned
|
isTransitioned
|
||||||
? "bg-chat_bg_light dark:bg-chat_bg_dark"
|
? "bg-bottom bg-chat_bg_light dark:bg-chat_bg_dark"
|
||||||
: "bg-search_bg_light dark:bg-search_bg_dark",
|
: "bg-top bg-search_bg_light dark:bg-search_bg_dark",
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
"size-full": !isTauri,
|
"size-full": !isTauri,
|
||||||
@@ -263,7 +340,10 @@ function SearchChat({
|
|||||||
"border-t border-t-[#999] dark:border-t-[#333]": isTauri && isWin10,
|
"border-t border-t-[#999] dark:border-t-[#333]": isTauri && isWin10,
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
style={{ opacity: blurred ? (opacity ?? 30) / 100 : 1 }}
|
style={{
|
||||||
|
backgroundSize: "auto 590px",
|
||||||
|
opacity: blurred ? blurOpacity / 100 : normalOpacity / 100,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region={isTauri}
|
data-tauri-drag-region={isTauri}
|
||||||
@@ -289,45 +369,53 @@ function SearchChat({
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* We don't want this inputbox when rendering View extensions */}
|
<div
|
||||||
{/* TODO: figure out a better way to disable this inputbox */}
|
data-tauri-drag-region={isTauri}
|
||||||
{!viewExtensionOpened && (
|
className={clsx(
|
||||||
<div
|
"relative p-2 w-full flex justify-center transition-all duration-500",
|
||||||
data-tauri-drag-region={isTauri}
|
{
|
||||||
className={`p-2 w-full flex justify-center transition-all duration-500 min-h-[82px] ${
|
"min-h-[84px]": visibleSearchBar() && visibleFilterBar(),
|
||||||
isTransitioned ? "border-t" : "border-b"
|
}
|
||||||
} border-[#E6E6E6] dark:border-[#272626]`}
|
)}
|
||||||
>
|
>
|
||||||
<InputBox
|
{!hideMiddleBorder && (
|
||||||
isChatMode={isChatMode}
|
<div
|
||||||
inputValue={input}
|
className={clsx(
|
||||||
onSend={handleSendMessage}
|
"pointer-events-none absolute left-0 right-0 h-[1px] bg-[#E6E6E6] dark:bg-[#272626]",
|
||||||
disabled={isTyping}
|
isTransitioned ? "top-0" : "bottom-0"
|
||||||
disabledChange={cancelChat}
|
)}
|
||||||
changeMode={changeMode}
|
|
||||||
changeInput={setInput}
|
|
||||||
isSearchActive={isSearchActive}
|
|
||||||
setIsSearchActive={toggleSearchActive}
|
|
||||||
isDeepThinkActive={isDeepThinkActive}
|
|
||||||
setIsDeepThinkActive={toggleDeepThinkActive}
|
|
||||||
isMCPActive={isMCPActive}
|
|
||||||
setIsMCPActive={toggleMCPActive}
|
|
||||||
setupWindowFocusListener={setupWindowFocusListener}
|
|
||||||
checkScreenPermission={checkScreenPermission}
|
|
||||||
requestScreenPermission={requestScreenPermission}
|
|
||||||
getScreenMonitors={getScreenMonitors}
|
|
||||||
getScreenWindows={getScreenWindows}
|
|
||||||
captureMonitorScreenshot={captureMonitorScreenshot}
|
|
||||||
captureWindowScreenshot={captureWindowScreenshot}
|
|
||||||
openFileDialog={openFileDialog}
|
|
||||||
getFileMetadata={getFileMetadata}
|
|
||||||
getFileIcon={getFileIcon}
|
|
||||||
hasModules={hasModules}
|
|
||||||
searchPlaceholder={searchPlaceholder}
|
|
||||||
chatPlaceholder={chatPlaceholder}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
<InputBox
|
||||||
|
isChatMode={isChatMode}
|
||||||
|
inputValue={input}
|
||||||
|
onSend={handleSendMessage}
|
||||||
|
disabled={isTyping}
|
||||||
|
disabledChange={cancelChat}
|
||||||
|
changeMode={changeMode}
|
||||||
|
changeInput={setInput}
|
||||||
|
isSearchActive={isSearchActive}
|
||||||
|
setIsSearchActive={toggleSearchActive}
|
||||||
|
isDeepThinkActive={isDeepThinkActive}
|
||||||
|
setIsDeepThinkActive={toggleDeepThinkActive}
|
||||||
|
isMCPActive={isMCPActive}
|
||||||
|
setIsMCPActive={toggleMCPActive}
|
||||||
|
setupWindowFocusListener={setupWindowFocusListener}
|
||||||
|
checkScreenPermission={checkScreenPermission}
|
||||||
|
requestScreenPermission={requestScreenPermission}
|
||||||
|
getScreenMonitors={getScreenMonitors}
|
||||||
|
getScreenWindows={getScreenWindows}
|
||||||
|
captureMonitorScreenshot={captureMonitorScreenshot}
|
||||||
|
captureWindowScreenshot={captureWindowScreenshot}
|
||||||
|
openFileDialog={openFileDialog}
|
||||||
|
getFileMetadata={getFileMetadata}
|
||||||
|
getFileIcon={getFileIcon}
|
||||||
|
hasModules={hasModules}
|
||||||
|
searchPlaceholder={searchPlaceholder}
|
||||||
|
chatPlaceholder={chatPlaceholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region={isTauri}
|
data-tauri-drag-region={isTauri}
|
||||||
|
|||||||
@@ -1,55 +1,135 @@
|
|||||||
import { Globe, Github } from "lucide-react";
|
import {
|
||||||
|
Globe,
|
||||||
|
Github,
|
||||||
|
Rocket,
|
||||||
|
BookOpen,
|
||||||
|
MessageCircleReply,
|
||||||
|
ScrollText,
|
||||||
|
SquareArrowOutUpRight,
|
||||||
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { OpenURLWithBrowser } from "@/utils";
|
import { OpenURLWithBrowser } from "@/utils";
|
||||||
import logoLight from "@/assets/images/logo-text-light.svg";
|
import lightLogo from "@/assets/images/logo-text-light.svg";
|
||||||
import logoDark from "@/assets/images/logo-text-dark.svg";
|
import darkLogo from "@/assets/images/logo-text-dark.svg";
|
||||||
import { useThemeStore } from "@/stores/themeStore";
|
import { useThemeStore } from "@/stores/themeStore";
|
||||||
|
import { cloneElement, ReactElement, useMemo } from "react";
|
||||||
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
|
|
||||||
|
interface Link {
|
||||||
|
icon: ReactElement;
|
||||||
|
label: string;
|
||||||
|
url?: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AboutView() {
|
export default function AboutView() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isDark = useThemeStore((state) => state.isDark);
|
const { isDark } = useThemeStore();
|
||||||
|
|
||||||
const logo = isDark ? logoDark : logoLight;
|
const links = useMemo<Link[]>(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
icon: <Rocket />,
|
||||||
|
label: t("settings.about.labels.changelog"),
|
||||||
|
url: "https://coco.rs/en/roadmap",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <BookOpen />,
|
||||||
|
label: t("settings.about.labels.docs"),
|
||||||
|
url: "https://docs.infinilabs.com/coco-app/main",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Github />,
|
||||||
|
label: "GitHub",
|
||||||
|
url: "https://github.com/infinilabs/coco-app",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Globe />,
|
||||||
|
label: t("settings.about.labels.officialWebsite"),
|
||||||
|
url: "https://coco.rs",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <MessageCircleReply />,
|
||||||
|
label: t("settings.about.labels.submitFeedback"),
|
||||||
|
url: "https://github.com/infinilabs/coco-app/issues",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <ScrollText />,
|
||||||
|
label: t("settings.about.labels.runningLog"),
|
||||||
|
onPress: platformAdapter.openLogDir,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const handleClick = (link: Link) => {
|
||||||
|
const { url, onPress } = link;
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
return OpenURLWithBrowser(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPress?.();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center flex-col h-[calc(100vh-170px)]">
|
<div className="flex h-[calc(100vh-170px)]">
|
||||||
<div>
|
<div className="flex flex-col items-center justify-center w-[70%] pr-10 text-[#999] text-sm">
|
||||||
<img
|
<img
|
||||||
src={logo}
|
src={isDark ? darkLogo : lightLogo}
|
||||||
className="w-48 dark:text-white"
|
className="h-14"
|
||||||
alt={t("settings.about.logo")}
|
alt={t("settings.about.logo")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4 text-base font-medium text-[#333] dark:text-white/80">
|
||||||
|
{t("settings.about.slogan")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10">
|
||||||
|
{t("settings.about.version", {
|
||||||
|
version: process.env.VERSION || "N/A",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
{t("settings.about.copyright", { year: new Date().getFullYear() })}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8 font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{t("settings.about.slogan")}
|
<div className="flex-1 flex flex-col items-center justify-center gap-4 pl-10 border-l border-[#e5e5e5] dark:border-[#4e4e56]">
|
||||||
</div>
|
{links.map((item) => {
|
||||||
<div className="flex justify-center items-center mt-10">
|
const { icon, label } = item;
|
||||||
<button
|
|
||||||
onClick={() => OpenURLWithBrowser("https://coco.rs")}
|
return (
|
||||||
className="w-6 h-6 mr-2.5 flex justify-center rounded-[6px] border-[1px] gray-200 dark:border-gray-700"
|
<div
|
||||||
aria-label={t("settings.about.website")}
|
key={label}
|
||||||
>
|
className="flex items-center justify-between w-full"
|
||||||
<Globe className="w-3 text-blue-500" />
|
>
|
||||||
</button>
|
<div className="flex items-center gap-2">
|
||||||
<button
|
{cloneElement(icon, {
|
||||||
onClick={() =>
|
className: "size-4 text-[#999]",
|
||||||
OpenURLWithBrowser("https://github.com/infinilabs/coco-app")
|
})}
|
||||||
}
|
|
||||||
className="w-6 h-6 flex justify-center rounded-[6px] border-[1px] gray-200 dark:border-gray-700"
|
<span
|
||||||
aria-label={t("settings.about.github")}
|
className="text-[#333] dark:text-white/80 cursor-pointer hover:text-[#027FFE] transition"
|
||||||
>
|
onClick={() => {
|
||||||
<Github className="w-3 text-blue-500" />
|
handleClick(item);
|
||||||
</button>
|
}}
|
||||||
</div>
|
>
|
||||||
<div className="mt-8 text-sm text-gray-500 dark:text-gray-400">
|
{label}
|
||||||
{t("settings.about.version", {
|
</span>
|
||||||
version: process.env.VERSION || "N/A",
|
</div>
|
||||||
|
|
||||||
|
<SquareArrowOutUpRight
|
||||||
|
className="text-[#027FFE] size-4 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
handleClick(item);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{t("settings.about.copyright", { year: new Date().getFullYear() })}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,13 @@
|
|||||||
import SettingsInput from "@/components/Settings/SettingsInput";
|
import SettingsInput from "@/components/Settings/SettingsInput";
|
||||||
import SettingsItem from "@/components/Settings/SettingsItem";
|
import SettingsItem from "@/components/Settings/SettingsItem";
|
||||||
import { useAppearanceStore } from "@/stores/appearanceStore";
|
import { useAppearanceStore } from "@/stores/appearanceStore";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
|
||||||
import { AppWindowMac } from "lucide-react";
|
import { AppWindowMac } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const Appearance = () => {
|
const Appearance = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const opacity = useAppearanceStore((state) => state.opacity);
|
const { normalOpacity, setNormalOpacity, blurOpacity, setBlurOpacity } =
|
||||||
const setOpacity = useAppearanceStore((state) => state.setOpacity);
|
useAppearanceStore();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unlisten = useAppearanceStore.subscribe((state) => {
|
|
||||||
platformAdapter.emitEvent("change-appearance-store", state);
|
|
||||||
});
|
|
||||||
|
|
||||||
return unlisten;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -27,16 +17,34 @@ const Appearance = () => {
|
|||||||
|
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
icon={AppWindowMac}
|
icon={AppWindowMac}
|
||||||
title={t("settings.advanced.appearance.opacity.title")}
|
title={t("settings.advanced.appearance.normalOpacity.title")}
|
||||||
description={t("settings.advanced.appearance.opacity.description")}
|
description={t(
|
||||||
|
"settings.advanced.appearance.normalOpacity.description"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<SettingsInput
|
<SettingsInput
|
||||||
type="number"
|
type="number"
|
||||||
min={10}
|
min={10}
|
||||||
max={100}
|
max={100}
|
||||||
value={opacity}
|
value={normalOpacity}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
return setOpacity(!value ? void 0 : Number(value));
|
return setNormalOpacity(!value ? 100 : Number(value));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
icon={AppWindowMac}
|
||||||
|
title={t("settings.advanced.appearance.blurOpacity.title")}
|
||||||
|
description={t("settings.advanced.appearance.blurOpacity.description")}
|
||||||
|
>
|
||||||
|
<SettingsInput
|
||||||
|
type="number"
|
||||||
|
min={10}
|
||||||
|
max={100}
|
||||||
|
value={blurOpacity}
|
||||||
|
onChange={(value) => {
|
||||||
|
return setBlurOpacity(!value ? 30 : Number(value));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ const Shortcuts = () => {
|
|||||||
<Button
|
<Button
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center justify-center size-8 rounded-md border border-black/5 dark:border-white/10 transition",
|
"flex items-center justify-center size-8 rounded-[6px] border border-black/5 dark:border-white/10 transition",
|
||||||
{
|
{
|
||||||
"hover:border-[#0072FF]": !disabled,
|
"hover:border-[#0072FF]": !disabled,
|
||||||
"opacity-70 cursor-not-allowed": disabled,
|
"opacity-70 cursor-not-allowed": disabled,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
AppWindowMac,
|
AppWindowMac,
|
||||||
|
ArrowUpWideNarrow,
|
||||||
MessageSquareMore,
|
MessageSquareMore,
|
||||||
|
PanelTopClose,
|
||||||
Search,
|
Search,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Unplug,
|
Unplug,
|
||||||
@@ -18,6 +20,7 @@ import SettingsInput from "@/components//Settings/SettingsInput";
|
|||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import UpdateSettings from "./components/UpdateSettings";
|
import UpdateSettings from "./components/UpdateSettings";
|
||||||
import SettingsToggle from "../SettingsToggle";
|
import SettingsToggle from "../SettingsToggle";
|
||||||
|
import { isNil } from "lodash-es";
|
||||||
|
|
||||||
const Advanced = () => {
|
const Advanced = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -57,6 +60,14 @@ const Advanced = () => {
|
|||||||
const setAllowSelfSignature = useConnectStore((state) => {
|
const setAllowSelfSignature = useConnectStore((state) => {
|
||||||
return state.setAllowSelfSignature;
|
return state.setAllowSelfSignature;
|
||||||
});
|
});
|
||||||
|
const {
|
||||||
|
searchDelay,
|
||||||
|
setSearchDelay,
|
||||||
|
compactModeAutoCollapseDelay,
|
||||||
|
setCompactModeAutoCollapseDelay,
|
||||||
|
} = useConnectStore();
|
||||||
|
|
||||||
|
const [localSearchResultWeight, setLocalSearchResultWeight] = useState(1);
|
||||||
|
|
||||||
useMount(async () => {
|
useMount(async () => {
|
||||||
const allowSelfSignature = await platformAdapter.invokeBackend<boolean>(
|
const allowSelfSignature = await platformAdapter.invokeBackend<boolean>(
|
||||||
@@ -64,6 +75,12 @@ const Advanced = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
setAllowSelfSignature(allowSelfSignature);
|
setAllowSelfSignature(allowSelfSignature);
|
||||||
|
|
||||||
|
const weight = await platformAdapter.invokeBackend<number>(
|
||||||
|
"get_local_query_source_weight"
|
||||||
|
);
|
||||||
|
|
||||||
|
setLocalSearchResultWeight(weight);
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -174,16 +191,20 @@ const Advanced = () => {
|
|||||||
|
|
||||||
<Shortcuts />
|
<Shortcuts />
|
||||||
|
|
||||||
|
<Appearance />
|
||||||
|
|
||||||
|
<UpdateSettings />
|
||||||
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
{t("settings.advanced.connect.title")}
|
{t("settings.advanced.other.title")}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
icon={Unplug}
|
icon={Unplug}
|
||||||
title={t("settings.advanced.connect.connectionTimeout.title")}
|
title={t("settings.advanced.other.connectionTimeout.title")}
|
||||||
description={t(
|
description={t(
|
||||||
"settings.advanced.connect.connectionTimeout.description"
|
"settings.advanced.other.connectionTimeout.description"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SettingsInput
|
<SettingsInput
|
||||||
@@ -198,8 +219,8 @@ const Advanced = () => {
|
|||||||
|
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
icon={Unplug}
|
icon={Unplug}
|
||||||
title={t("settings.advanced.connect.queryTimeout.title")}
|
title={t("settings.advanced.other.queryTimeout.title")}
|
||||||
description={t("settings.advanced.connect.queryTimeout.description")}
|
description={t("settings.advanced.other.queryTimeout.description")}
|
||||||
>
|
>
|
||||||
<SettingsInput
|
<SettingsInput
|
||||||
type="number"
|
type="number"
|
||||||
@@ -211,15 +232,30 @@ const Advanced = () => {
|
|||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
icon={Unplug}
|
||||||
|
title={t("settings.advanced.other.searchDelay.title")}
|
||||||
|
description={t("settings.advanced.other.searchDelay.description")}
|
||||||
|
>
|
||||||
|
<SettingsInput
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={searchDelay}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSearchDelay(isNil(value) ? 0 : Number(value));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
icon={ShieldCheck}
|
icon={ShieldCheck}
|
||||||
title={t("settings.advanced.connect.allowSelfSignature.title")}
|
title={t("settings.advanced.other.allowSelfSignature.title")}
|
||||||
description={t(
|
description={t(
|
||||||
"settings.advanced.connect.allowSelfSignature.description"
|
"settings.advanced.other.allowSelfSignature.description"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
label={t("settings.advanced.connect.allowSelfSignature.title")}
|
label={t("settings.advanced.other.allowSelfSignature.title")}
|
||||||
checked={allowSelfSignature}
|
checked={allowSelfSignature}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setAllowSelfSignature(value);
|
setAllowSelfSignature(value);
|
||||||
@@ -230,11 +266,62 @@ const Advanced = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
icon={ArrowUpWideNarrow}
|
||||||
|
title={t("settings.advanced.other.localSearchResultWeight.title")}
|
||||||
|
description={t(
|
||||||
|
"settings.advanced.other.localSearchResultWeight.description"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
value={localSearchResultWeight}
|
||||||
|
onChange={(event) => {
|
||||||
|
const weight = Number(event.target.value);
|
||||||
|
|
||||||
|
setLocalSearchResultWeight(weight);
|
||||||
|
|
||||||
|
platformAdapter.invokeBackend("set_local_query_source_weight", {
|
||||||
|
value: weight,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="0.5">
|
||||||
|
{t("settings.advanced.other.localSearchResultWeight.options.low")}
|
||||||
|
</option>
|
||||||
|
<option value="1">
|
||||||
|
{t(
|
||||||
|
"settings.advanced.other.localSearchResultWeight.options.medium"
|
||||||
|
)}
|
||||||
|
</option>
|
||||||
|
<option value="2">
|
||||||
|
{t(
|
||||||
|
"settings.advanced.other.localSearchResultWeight.options.high"
|
||||||
|
)}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
icon={PanelTopClose}
|
||||||
|
title={t(
|
||||||
|
"settings.advanced.other.compactModeAutoCollapseDelay.title"
|
||||||
|
)}
|
||||||
|
description={t(
|
||||||
|
"settings.advanced.other.compactModeAutoCollapseDelay.description"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SettingsInput
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={compactModeAutoCollapseDelay}
|
||||||
|
onChange={(value) => {
|
||||||
|
setCompactModeAutoCollapseDelay(!value ? 0 : Number(value));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Appearance />
|
|
||||||
|
|
||||||
<UpdateSettings />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FC, MouseEvent, useContext, useMemo, useState } from "react";
|
import { FC, MouseEvent, useContext, useMemo, useState } from "react";
|
||||||
import { useReactive } from "ahooks";
|
import { useMount, useReactive } from "ahooks";
|
||||||
import { ChevronRight, LoaderCircle } from "lucide-react";
|
import { ChevronRight, LoaderCircle } from "lucide-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { isArray, startCase, sortBy } from "lodash-es";
|
import { isArray, startCase, sortBy } from "lodash-es";
|
||||||
@@ -20,11 +20,12 @@ const Content = () => {
|
|||||||
return rootState.extensions.map((item) => {
|
return rootState.extensions.map((item) => {
|
||||||
const { id } = item;
|
const { id } = item;
|
||||||
|
|
||||||
return <Item key={id} {...item} level={1} />;
|
return <Item key={id} extension={item} level={1} />;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ItemProps extends Extension {
|
interface ItemProps {
|
||||||
|
extension: Extension;
|
||||||
level: number;
|
level: number;
|
||||||
parentId?: ExtensionId;
|
parentId?: ExtensionId;
|
||||||
parentDeveloper?: string;
|
parentDeveloper?: string;
|
||||||
@@ -42,19 +43,8 @@ const subExtensionCommand: Partial<Record<ExtensionId, string>> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Item: FC<ItemProps> = (props) => {
|
const Item: FC<ItemProps> = (props) => {
|
||||||
const {
|
const { extension, level, parentId, parentDeveloper, parentDisabled } = props;
|
||||||
id,
|
const { id, icon, name, type, platforms, developer, enabled } = extension;
|
||||||
icon,
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
level,
|
|
||||||
platforms,
|
|
||||||
developer,
|
|
||||||
enabled,
|
|
||||||
parentId,
|
|
||||||
parentDeveloper,
|
|
||||||
parentDisabled,
|
|
||||||
} = props;
|
|
||||||
const { rootState } = useContext(ExtensionsContext);
|
const { rootState } = useContext(ExtensionsContext);
|
||||||
const state = useReactive<ItemState>({
|
const state = useReactive<ItemState>({
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -63,6 +53,18 @@ const Item: FC<ItemProps> = (props) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { disabledExtensions, setDisabledExtensions } = useExtensionsStore();
|
const { disabledExtensions, setDisabledExtensions } = useExtensionsStore();
|
||||||
const [selfDisabled, setSelfDisabled] = useState(!enabled);
|
const [selfDisabled, setSelfDisabled] = useState(!enabled);
|
||||||
|
const [compatible, setCompatible] = useState(true);
|
||||||
|
|
||||||
|
useMount(async () => {
|
||||||
|
const compatible = await platformAdapter.invokeBackend<boolean>(
|
||||||
|
"is_extension_compatible",
|
||||||
|
{
|
||||||
|
extension,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
setCompatible(compatible);
|
||||||
|
});
|
||||||
|
|
||||||
const bundleId = {
|
const bundleId = {
|
||||||
developer: developer ?? parentDeveloper,
|
developer: developer ?? parentDeveloper,
|
||||||
@@ -71,7 +73,7 @@ const Item: FC<ItemProps> = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hasSubExtensions = () => {
|
const hasSubExtensions = () => {
|
||||||
const { commands, scripts, quicklinks } = props;
|
const { commands, scripts, quicklinks } = extension;
|
||||||
|
|
||||||
if (subExtensionCommand[id]) {
|
if (subExtensionCommand[id]) {
|
||||||
return true;
|
return true;
|
||||||
@@ -87,7 +89,7 @@ const Item: FC<ItemProps> = (props) => {
|
|||||||
const getSubExtensions = async () => {
|
const getSubExtensions = async () => {
|
||||||
state.loading = true;
|
state.loading = true;
|
||||||
|
|
||||||
const { commands, scripts, quicklinks } = props;
|
const { commands, scripts, quicklinks } = extension;
|
||||||
|
|
||||||
let subExtensions: Extension[] = [];
|
let subExtensions: Extension[] = [];
|
||||||
|
|
||||||
@@ -117,12 +119,16 @@ const Item: FC<ItemProps> = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isDisabled = useMemo(() => {
|
const isDisabled = useMemo(() => {
|
||||||
|
if (!compatible) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (level === 1) {
|
if (level === 1) {
|
||||||
return selfDisabled;
|
return selfDisabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
return parentDisabled || selfDisabled;
|
return parentDisabled || selfDisabled;
|
||||||
}, [parentDisabled, selfDisabled]);
|
}, [parentDisabled, selfDisabled, compatible]);
|
||||||
|
|
||||||
const editable = useMemo(() => {
|
const editable = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
@@ -134,7 +140,7 @@ const Item: FC<ItemProps> = (props) => {
|
|||||||
}, [type]);
|
}, [type]);
|
||||||
|
|
||||||
const renderAlias = () => {
|
const renderAlias = () => {
|
||||||
const { alias } = props;
|
const { alias } = extension;
|
||||||
|
|
||||||
const handleChange = (value: string) => {
|
const handleChange = (value: string) => {
|
||||||
platformAdapter.invokeBackend("set_extension_alias", {
|
platformAdapter.invokeBackend("set_extension_alias", {
|
||||||
@@ -173,7 +179,7 @@ const Item: FC<ItemProps> = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderHotkey = () => {
|
const renderHotkey = () => {
|
||||||
const { hotkey } = props;
|
const { hotkey } = extension;
|
||||||
|
|
||||||
const handleChange = (value: string) => {
|
const handleChange = (value: string) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -246,7 +252,7 @@ const Item: FC<ItemProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx("flex items-center justify-end", {
|
className={clsx("flex items-center justify-end", {
|
||||||
"opacity-50 pointer-events-none": parentDisabled,
|
"opacity-50 pointer-events-none": !compatible || parentDisabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
@@ -286,7 +292,7 @@ const Item: FC<ItemProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={clsx("-mx-2 px-2 text-sm rounded-md", {
|
className={clsx("-mx-2 px-2 text-sm rounded-[6px]", {
|
||||||
"bg-[#f0f6fe] dark:bg-gray-700":
|
"bg-[#f0f6fe] dark:bg-gray-700":
|
||||||
id === rootState.activeExtension?.id,
|
id === rootState.activeExtension?.id,
|
||||||
})}
|
})}
|
||||||
@@ -294,7 +300,7 @@ const Item: FC<ItemProps> = (props) => {
|
|||||||
<div
|
<div
|
||||||
className="flex items-center justify-between gap-2 h-8"
|
className="flex items-center justify-between gap-2 h-8"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
rootState.activeExtension = props;
|
rootState.activeExtension = extension;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -328,7 +334,7 @@ const Item: FC<ItemProps> = (props) => {
|
|||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
src={platformAdapter.convertFileSrc(icon)}
|
src={platformAdapter.convertFileSrc(icon)}
|
||||||
className="size-full"
|
className="size-full dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -356,7 +362,7 @@ const Item: FC<ItemProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<Item
|
<Item
|
||||||
key={item.id}
|
key={item.id}
|
||||||
{...item}
|
extension={item}
|
||||||
level={level + 1}
|
level={level + 1}
|
||||||
parentId={id}
|
parentId={id}
|
||||||
parentDeveloper={developer}
|
parentDeveloper={developer}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ const Applications = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="w-full h-8 my-4 text-[#0087FF] border border-[#EEF0F3] hover:!border-[#0087FF] dark:border-gray-700 rounded-md transition"
|
className="w-full h-8 my-4 text-[#0087FF] border border-[#EEF0F3] hover:!border-[#0087FF] dark:border-gray-700 rounded-[6px] transition"
|
||||||
onClick={handleReindex}
|
onClick={handleReindex}
|
||||||
>
|
>
|
||||||
{t("settings.extensions.application.details.reindex")}
|
{t("settings.extensions.application.details.reindex")}
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ const DirectoryScope: FC<DirectoryScopeProps> = (props) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="w-full h-8 text-[#0087FF] border border-[#EEF0F3] hover:!border-[#0087FF] dark:border-gray-700 rounded-md transition"
|
className="w-full h-8 text-[#0087FF] border border-[#EEF0F3] hover:!border-[#0087FF] dark:border-gray-700 rounded-[6px] transition"
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
>
|
>
|
||||||
{t("settings.extensions.directoryScope.button.addDirectories")}
|
{t("settings.extensions.directoryScope.button.addDirectories")}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ const FileSearch = () => {
|
|||||||
{t("settings.extensions.fileSearch.label.searchFileTypes")}
|
{t("settings.extensions.fileSearch.label.searchFileTypes")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 p-2 border rounded-md dark:border-gray-700">
|
<div className="flex flex-wrap gap-2 p-2 border rounded-[6px] dark:border-gray-700">
|
||||||
{config.file_types.map((item) => {
|
{config.file_types.map((item) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useContext } from "react";
|
import { useContext, useState } from "react";
|
||||||
|
|
||||||
import { ExtensionsContext } from "../..";
|
import { ExtensionsContext } from "../..";
|
||||||
import Applications from "./Applications";
|
import Applications from "./Applications";
|
||||||
@@ -8,11 +8,12 @@ import SharedAi from "./SharedAi";
|
|||||||
import AiOverview from "./AiOverview";
|
import AiOverview from "./AiOverview";
|
||||||
import Calculator from "./Calculator";
|
import Calculator from "./Calculator";
|
||||||
import FileSearch from "./FileSearch";
|
import FileSearch from "./FileSearch";
|
||||||
import { Ellipsis } from "lucide-react";
|
import { Ellipsis, Info } from "lucide-react";
|
||||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "ahooks";
|
||||||
|
|
||||||
const Details = () => {
|
const Details = () => {
|
||||||
const { rootState } = useContext(ExtensionsContext);
|
const { rootState } = useContext(ExtensionsContext);
|
||||||
@@ -33,6 +34,23 @@ const Details = () => {
|
|||||||
});
|
});
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [compatible, setCompatible] = useState(true);
|
||||||
|
|
||||||
|
useAsyncEffect(async () => {
|
||||||
|
if (rootState.activeExtension?.id) {
|
||||||
|
const compatible = await platformAdapter.invokeBackend<boolean>(
|
||||||
|
"is_extension_compatible",
|
||||||
|
{
|
||||||
|
extension: rootState.activeExtension,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
setCompatible(compatible);
|
||||||
|
} else {
|
||||||
|
setCompatible(true);
|
||||||
|
}
|
||||||
|
}, [rootState.activeExtension?.id]);
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (!rootState.activeExtension) return;
|
if (!rootState.activeExtension) return;
|
||||||
|
|
||||||
@@ -77,7 +95,7 @@ const Details = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex-1 h-full pr-4 pb-4 overflow-auto">
|
<div className="flex-1 h-full pr-4 pb-4 overflow-auto">
|
||||||
<div className="flex items-start justify-between gap-4 mb-4">
|
<div className="flex items-start justify-between gap-4 mb-4">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
<h2 className="m-0 text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{rootState.activeExtension?.name}
|
{rootState.activeExtension?.name}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
@@ -130,6 +148,16 @@ const Details = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!compatible && (
|
||||||
|
<div className="-mt-1 mb-3 bg-red-50 p-2 rounded">
|
||||||
|
<Info className="inline-flex size-4 mr-1 text-red-600" />
|
||||||
|
|
||||||
|
<span className="text-[#333]">
|
||||||
|
{t("settings.extensions.hints.incompatible")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="text-sm">{renderContent()}</div>
|
<div className="text-sm">{renderContent()}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import Details from "./components/Details";
|
|||||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||||
import SettingsInput from "../SettingsInput";
|
import SettingsInput from "../SettingsInput";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import { useAppStore } from "@/stores/appStore";
|
||||||
|
import { installExtensionError } from "@/utils";
|
||||||
|
|
||||||
export type ExtensionId = LiteralUnion<
|
export type ExtensionId = LiteralUnion<
|
||||||
| "Applications"
|
| "Applications"
|
||||||
@@ -32,7 +33,9 @@ type ExtensionType =
|
|||||||
| "setting"
|
| "setting"
|
||||||
| "calculator"
|
| "calculator"
|
||||||
| "command"
|
| "command"
|
||||||
| "ai_extension";
|
| "ai_extension"
|
||||||
|
| "view"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
export type ExtensionPlatform = "windows" | "macos" | "linux";
|
export type ExtensionPlatform = "windows" | "macos" | "linux";
|
||||||
|
|
||||||
@@ -62,6 +65,12 @@ export interface ExtensionPermission {
|
|||||||
api: string[] | null;
|
api: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ViewExtensionUISettings {
|
||||||
|
search_bar: boolean;
|
||||||
|
filter_bar: boolean;
|
||||||
|
footer: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Extension {
|
export interface Extension {
|
||||||
id: ExtensionId;
|
id: ExtensionId;
|
||||||
type: ExtensionType;
|
type: ExtensionType;
|
||||||
@@ -137,6 +146,8 @@ export const Extensions = () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log("extensions", cloneDeep(extensions));
|
||||||
|
|
||||||
state.extensions = sortBy(extensions, ["name"]);
|
state.extensions = sortBy(extensions, ["name"]);
|
||||||
|
|
||||||
if (configId) {
|
if (configId) {
|
||||||
@@ -181,7 +192,7 @@ export const Extensions = () => {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<Menu>
|
<Menu>
|
||||||
<MenuButton className="flex items-center justify-center size-6 border rounded-md dark:border-gray-700 hover:!border-[#0096FB] transition">
|
<MenuButton className="flex items-center justify-center size-6 border rounded-[6px] dark:border-gray-700 hover:!border-[#0096FB] transition">
|
||||||
<Plus className="size-4 text-[#0096FB]" />
|
<Plus className="size-4 text-[#0096FB]" />
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
|
|
||||||
@@ -222,21 +233,7 @@ export const Extensions = () => {
|
|||||||
"info"
|
"info"
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = String(error);
|
installExtensionError(String(error));
|
||||||
|
|
||||||
if (errorMessage === "already imported") {
|
|
||||||
addError(
|
|
||||||
t(
|
|
||||||
"settings.extensions.hints.extensionAlreadyImported"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else if (errorMessage === "incompatible") {
|
|
||||||
addError(
|
|
||||||
t("settings.extensions.hints.incompatibleExtension")
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
addError(t("settings.extensions.hints.importFailed"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -248,7 +245,7 @@ export const Extensions = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between gap-6 my-4">
|
<div className="flex justify-between gap-6 my-4">
|
||||||
<div className="flex h-8 border dark:border-gray-700 rounded-md overflow-hidden">
|
<div className="flex h-8 border dark:border-gray-700 rounded-[6px] overflow-hidden">
|
||||||
{state.categories.map((item) => {
|
{state.categories.map((item) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, cloneElement, ReactElement } from "react";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
Monitor,
|
Monitor,
|
||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
Tags,
|
Tags,
|
||||||
// Trash2,
|
// Trash2,
|
||||||
Globe,
|
Globe,
|
||||||
|
PictureInPicture2,
|
||||||
|
PanelTop,
|
||||||
|
RectangleHorizontal,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { isTauri } from "@tauri-apps/api/core";
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
@@ -31,6 +34,8 @@ import {
|
|||||||
unregister_shortcut,
|
unregister_shortcut,
|
||||||
} from "@/commands";
|
} from "@/commands";
|
||||||
import platformAdapter from "@/utils/platformAdapter";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useAppearanceStore, WindowMode } from "@/stores/appearanceStore";
|
||||||
|
|
||||||
export function ThemeOption({
|
export function ThemeOption({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
@@ -76,6 +81,7 @@ export default function GeneralSettings() {
|
|||||||
const [launchAtLogin, setLaunchAtLogin] = useState(true);
|
const [launchAtLogin, setLaunchAtLogin] = useState(true);
|
||||||
|
|
||||||
const { showTooltip, setShowTooltip, language, setLanguage } = useAppStore();
|
const { showTooltip, setShowTooltip, language, setLanguage } = useAppStore();
|
||||||
|
const { windowMode, setWindowMode } = useAppearanceStore();
|
||||||
|
|
||||||
const fetchAutoStartStatus = async () => {
|
const fetchAutoStartStatus = async () => {
|
||||||
if (isTauri()) {
|
if (isTauri()) {
|
||||||
@@ -176,6 +182,20 @@ export default function GeneralSettings() {
|
|||||||
|
|
||||||
const currentLanguage = language || i18n.language;
|
const currentLanguage = language || i18n.language;
|
||||||
|
|
||||||
|
const windowModes: Array<{
|
||||||
|
icon: ReactElement;
|
||||||
|
value: WindowMode;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
icon: <PanelTop />,
|
||||||
|
value: "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <RectangleHorizontal />,
|
||||||
|
value: "compact",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
@@ -239,6 +259,52 @@ export default function GeneralSettings() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
icon={PictureInPicture2}
|
||||||
|
title={t("settings.windowMode.title")}
|
||||||
|
description={t("settings.windowMode.description")}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{windowModes.map((item) => {
|
||||||
|
const { icon, value } = item;
|
||||||
|
|
||||||
|
const label = t(`settings.windowMode.${value}`);
|
||||||
|
|
||||||
|
let isSelected = value === windowMode;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => {
|
||||||
|
setWindowMode(value);
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
"p-4 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 flex flex-col items-center justify-center space-y-2 transition-all",
|
||||||
|
{
|
||||||
|
"!border-blue-500 bg-blue-50 dark:bg-blue-900/20":
|
||||||
|
isSelected,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
{cloneElement(icon, {
|
||||||
|
className: clsx({
|
||||||
|
"text-blue-500": isSelected,
|
||||||
|
}),
|
||||||
|
})}
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={clsx(`text-sm font-medium`, {
|
||||||
|
"text-blue-500": isSelected,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
icon={Globe}
|
icon={Globe}
|
||||||
title={t("settings.language.title")}
|
title={t("settings.language.title")}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const SettingsInput: FC<SettingsInputProps> = (props) => {
|
|||||||
{...rest}
|
{...rest}
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"w-20 h-8 px-2 rounded-md border bg-transparent border-black/5 dark:border-white/10 hover:!border-[#0072FF] focus:!border-[#0072FF] transition",
|
"w-20 h-8 px-2 rounded-[6px] border bg-transparent border-black/5 dark:border-white/10 hover:!border-[#0072FF] focus:!border-[#0072FF] transition",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ interface SettingsItemProps {
|
|||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
children: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsItem({
|
export default function SettingsItem({
|
||||||
@@ -14,7 +14,7 @@ export default function SettingsItem({
|
|||||||
children,
|
children,
|
||||||
}: SettingsItemProps) {
|
}: SettingsItemProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-6">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Icon className="h-5 min-w-5 text-gray-400 dark:text-gray-500" />
|
<Icon className="h-5 min-w-5 text-gray-400 dark:text-gray-500" />
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const SettingsSelectPro: FC<SettingsSelectProProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative">
|
<div ref={containerRef} className="relative">
|
||||||
<div
|
<div
|
||||||
className="flex items-center h-8 px-3 truncate rounded-md border dark:bg-[#1F2937] bg-white dark:border-[#374151]"
|
className="flex items-center h-8 px-3 truncate rounded-[6px] border dark:bg-[#1F2937] bg-white dark:border-[#374151]"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
>
|
>
|
||||||
{option?.[labelField] ?? (
|
{option?.[labelField] ?? (
|
||||||
@@ -57,7 +57,7 @@ const SettingsSelectPro: FC<SettingsSelectProProps> = (props) => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"absolute z-100 top-10 left-0 right-0 rounded-md py-2 border dark:border-[#374151] bg-white dark:bg-[#1F2937] shadow-[0_5px_15px_rgba(0,0,0,0.2)] dark:shadow-[0_5px_10px_rgba(0,0,0,0.3)]",
|
"absolute z-100 top-10 left-0 right-0 rounded-[6px] py-2 border dark:border-[#374151] bg-white dark:bg-[#1F2937] shadow-[0_5px_15px_rgba(0,0,0,0.2)] dark:shadow-[0_5px_10px_rgba(0,0,0,0.3)]",
|
||||||
{
|
{
|
||||||
hidden: !open,
|
hidden: !open,
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ const SettingsSelectPro: FC<SettingsSelectProProps> = (props) => {
|
|||||||
<div
|
<div
|
||||||
key={item?.[valueField] ?? index}
|
key={item?.[valueField] ?? index}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"h-8 leading-8 px-2 rounded-md hover:bg-[#EDEDED] hover:dark:bg-[#374151] transition cursor-pointer",
|
"h-8 leading-8 px-2 rounded-[6px] hover:bg-[#EDEDED] hover:dark:bg-[#374151] transition cursor-pointer",
|
||||||
{
|
{
|
||||||
"bg-[#EDEDED] dark:bg-[#374151]":
|
"bg-[#EDEDED] dark:bg-[#374151]":
|
||||||
value === item?.[valueField],
|
value === item?.[valueField],
|
||||||
|
|||||||
@@ -131,9 +131,8 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
|
|||||||
|
|
||||||
const { skipVersions, updateInfo } = useUpdateStore.getState();
|
const { skipVersions, updateInfo } = useUpdateStore.getState();
|
||||||
|
|
||||||
if(updateInfo?.version){
|
if (updateInfo?.version) {
|
||||||
setSkipVersions([...skipVersions, updateInfo.version]);
|
setSkipVersions([...skipVersions, updateInfo.version]);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isCheckPage ? hide_check() : setVisible(false);
|
isCheckPage ? hide_check() : setVisible(false);
|
||||||
@@ -143,6 +142,7 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
|
|||||||
<Dialog
|
<Dialog
|
||||||
open={isCheckPage ? true : visible}
|
open={isCheckPage ? true : visible}
|
||||||
as="div"
|
as="div"
|
||||||
|
id="update-app-dialog"
|
||||||
className="relative z-10 focus:outline-none"
|
className="relative z-10 focus:outline-none"
|
||||||
onClose={noop}
|
onClose={noop}
|
||||||
>
|
>
|
||||||
@@ -154,6 +154,7 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex min-h-full items-center justify-center",
|
"flex min-h-full items-center justify-center",
|
||||||
!isCheckPage && "p-4"
|
!isCheckPage && "p-4"
|
||||||
@@ -161,11 +162,13 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
|
|||||||
>
|
>
|
||||||
<DialogPanel
|
<DialogPanel
|
||||||
transition
|
transition
|
||||||
className={`relative w-[340px] py-8 flex flex-col items-center ${
|
className={clsx(
|
||||||
isCheckPage
|
"relative w-[340px] py-8 flex flex-col items-center",
|
||||||
? ""
|
{
|
||||||
: "rounded-lg bg-white dark:bg-[#333] border border-[#EDEDED] dark:border-black/20 shadow-md"
|
"rounded-lg bg-white dark:bg-[#333] border border-[#EDEDED] dark:border-black/20 shadow-md":
|
||||||
}`}
|
!isCheckPage,
|
||||||
|
}
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{!isCheckPage && isOptional && (
|
{!isCheckPage && isOptional && (
|
||||||
<X
|
<X
|
||||||
@@ -238,7 +241,7 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{!isCheckPage && updateInfo && isOptional && (
|
{updateInfo && isOptional && (
|
||||||
<div
|
<div
|
||||||
className={clsx("text-xs text-[#999]", cursorClassName)}
|
className={clsx("text-xs text-[#999]", cursorClassName)}
|
||||||
onClick={handleSkip}
|
onClick={handleSkip}
|
||||||
|
|||||||
26
src/components/WebLogin/LoginButton.tsx
Normal file
26
src/components/WebLogin/LoginButton.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { useAppStore } from "@/stores/appStore";
|
||||||
|
import { Button } from "@headlessui/react";
|
||||||
|
import { SquareArrowOutUpRight } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
const LoginButton = () => {
|
||||||
|
const { endpoint } = useAppStore();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
window.open(endpoint);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className="px-6 h-8 text-white bg-[#0287FF] flex rounded-[8px] items-center justify-center gap-1"
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<span>{t("webLogin.buttons.login")}</span>
|
||||||
|
|
||||||
|
<SquareArrowOutUpRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginButton;
|
||||||
48
src/components/WebLogin/RefreshButton.tsx
Normal file
48
src/components/WebLogin/RefreshButton.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
|
import { FC, useState } from "react";
|
||||||
|
import { Button, ButtonProps } from "@headlessui/react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import { useWebConfigStore } from "@/stores/webConfigStore";
|
||||||
|
import VisibleKey from "../Common/VisibleKey";
|
||||||
|
|
||||||
|
const RefreshButton: FC<ButtonProps> = (props) => {
|
||||||
|
const { className, ...rest } = props;
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const { onRefresh } = useWebConfigStore();
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
try {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
|
||||||
|
await onRefresh();
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
{...rest}
|
||||||
|
onClick={handleRefresh}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center justify-center size-6 bg-white dark:bg-[#202126] rounded-[8px] border dark:border-white/10",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
>
|
||||||
|
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
|
||||||
|
<RefreshCw
|
||||||
|
className={clsx(
|
||||||
|
"size-3 text-[#0287FF] transition-transform duration-1000",
|
||||||
|
{
|
||||||
|
"animate-spin": isRefreshing,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</VisibleKey>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RefreshButton;
|
||||||
24
src/components/WebLogin/UserAvatar.tsx
Normal file
24
src/components/WebLogin/UserAvatar.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import { LucideProps, User } from "lucide-react";
|
||||||
|
import { FC, HTMLAttributes } from "react";
|
||||||
|
|
||||||
|
interface UserAvatarProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
icon?: LucideProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserAvatar: FC<UserAvatarProps> = (props) => {
|
||||||
|
const { className, icon } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center justify-center size-5 rounded-full border dark:border-white/10 overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<User {...icon} className={clsx("size-4", icon?.className)}></User>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserAvatar;
|
||||||
105
src/components/WebLogin/index.tsx
Normal file
105
src/components/WebLogin/index.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||||
|
import { useWebConfigStore } from "@/stores/webConfigStore";
|
||||||
|
import { LogOut } from "lucide-react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { Post } from "@/api/axiosRequest";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import UserAvatar from "./UserAvatar";
|
||||||
|
import FontIcon from "../Common/Icons/FontIcon";
|
||||||
|
import RefreshButton from "./RefreshButton";
|
||||||
|
import LoginButton from "./LoginButton";
|
||||||
|
import { FC } from "react";
|
||||||
|
import Copyright from "../Common/Copyright";
|
||||||
|
|
||||||
|
interface WebLoginProps {
|
||||||
|
panelClassName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WebLogin: FC<WebLoginProps> = (props) => {
|
||||||
|
const { panelClassName } = props;
|
||||||
|
const { integration, loginInfo, setIntegration, setLoginInfo } =
|
||||||
|
useWebConfigStore();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Popover>
|
||||||
|
<PopoverButton>
|
||||||
|
{loginInfo ? (
|
||||||
|
<UserAvatar />
|
||||||
|
) : (
|
||||||
|
<FontIcon
|
||||||
|
name="font_coco-logo-line"
|
||||||
|
className="size-5 text-[#999]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PopoverButton>
|
||||||
|
|
||||||
|
<PopoverPanel
|
||||||
|
className={clsx(
|
||||||
|
"absolute z-50 w-[300px] rounded-xl bg-white dark:bg-[#202126] text-sm/6 text-[#333] dark:text-[#D8D8D8] shadow-lg border dark:border-white/10 -translate-y-2",
|
||||||
|
panelClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span>{t("webLogin.title")}</span>
|
||||||
|
|
||||||
|
<RefreshButton />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="py-2">
|
||||||
|
{loginInfo ? (
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<UserAvatar
|
||||||
|
className="!size-12"
|
||||||
|
icon={{ className: "!size-6" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{loginInfo.name}</span>
|
||||||
|
<span className="text-[#999]">{loginInfo.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="flex items-center justify-center size-6 bg-white dark:bg-[#202126] rounded-[8px] border dark:border-white/10"
|
||||||
|
onClick={async () => {
|
||||||
|
await Post("/account/logout", void 0);
|
||||||
|
|
||||||
|
setIntegration(void 0);
|
||||||
|
setLoginInfo(void 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogOut
|
||||||
|
className={clsx(
|
||||||
|
"size-3 text-[#0287FF] transition-transform duration-1000"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<span className="text-[#999]">
|
||||||
|
{integration?.guest?.enabled
|
||||||
|
? t("webLogin.hints.tourist")
|
||||||
|
: t("webLogin.hints.login")}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<LoginButton />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 border-t dark:border-t-white/10">
|
||||||
|
<Copyright />
|
||||||
|
</div>
|
||||||
|
</PopoverPanel>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WebLogin;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user