77 Commits

Author SHA1 Message Date
medcl
8ac0065234 v0.9.0 2025-11-24 11:24:37 +08:00
ayangweb
31806b6057 fix: persist configuration settings properly (#987)
* fix: persist configuration settings properly

* docs: update changelog
2025-11-19 16:09:27 +08:00
SteveLauC
533bfaf45b fix: search_extension should not panic when ext is not found (#983)
This commit fixes a bug that the search_extension() function panics
when the "GET /store/_search" interface returns a 404 response.

```
GET /store/_search?query=<query string>
{"_id":"_search","result":"not_found"}
```

It also improves the panic message by including varaible "response" in it,
so that we can inspect the actual response.

```
let hits_json = response.remove("hits").unwrap_or_else(|| {
    panic!(
        "the JSON response should contain field [hits], response [{:?}]",
        response
    )
});
```
2025-11-19 10:10:59 +08:00
ayangweb
459705af70 refactor: simplify fetching the screen under the mouse cursor (#985) 2025-11-19 09:39:36 +08:00
Hardy
84e556ddad chore: update release notes for publish 0.9.0 (#986)
Co-authored-by: github-actions <github-actions@github.com>
2025-11-19 09:38:37 +08:00
Medcl
b50a20c7d4 fix release notes for version 0.9.0 (#982) 2025-11-18 13:50:57 +08:00
ayangweb
d4ccd780b2 feat: add auto collapse delay for compact mode (#981)
* feat: add auto collapse delay for compact mode

* refactor: change i18n

* docs: update changelog
2025-11-17 20:56:44 +08:00
SteveLauC
aef934e9a2 feat: advanced settings search debounce & local query source weight (#950)
* wip

* wip

* wip

* feat: add search delay

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* docs: update changelog

---------

Co-authored-by: ayang <473033518@qq.com>
Co-authored-by: ayangweb <75017711+ayangweb@users.noreply.github.com>
2025-11-17 18:35:30 +08:00
ayangweb
1fb927c26b fix: fix quick ai not continuing conversation (#979)
* fix: fix quick ai not continuing conversation

* docs: update changelog
2025-11-16 20:59:20 +08:00
ayangweb
8974624b3c refactor: remove url encoding from query parameters (#978) 2025-11-16 14:05:43 +08:00
ayangweb
d99b35bf4c fix: prevent duplicate login success messages (#977)
* fix: prevent duplicate login success messages

* refactor: i18n

* refactor: update

* docs: update changelog
2025-11-14 15:42:56 +08:00
SteveLauC
594d0ffe3f fix: allow any http/https requests (#976) 2025-11-14 11:53:29 +08:00
ayangweb
7b08a87766 refactor: replace all rounded-md with rounded-[6px] (#975) 2025-11-14 11:53:14 +08:00
SteveLauC
ab5ca24270 fix: correct csp setting (#974) 2025-11-14 10:34:29 +08:00
ayangweb
c593b07187 refactor: only call oauth in settings window (#972) 2025-11-13 19:50:59 +08:00
SteveLauC
c088dde749 refactor(view extension): load HTML/resources via local HTTP server (#973)
Previously, View extensions loaded their HTML files directly from disk.
Now, Coco starts a lightweight local HTTP server to serve the static files,
and the extension loads them via HTTP instead.

This refactoring is needed because Tauri is not allowed to load local
files directly, we have to call convertFileSrc() to do the URL
conversion to make it work. In previous implementations, we did such
conversions to all the paths specified in the HTML file, but we realized
that there are paths in JS/CSS files as well, and it is impossible to
convert them all. So we have to change the way how view extensions load
their files.
2025-11-13 19:50:32 +08:00
ayangweb
1fdf7c499d refactor: change web login position (#971)
* refactor: change web login position

* refactor: update
2025-11-08 21:02:04 +08:00
ayangweb
01dfc616d4 refactor: render footer content conditionally (#970) 2025-11-08 10:51:09 +08:00
BiggerRain
8d7d655581 fix: web page login state (#969) 2025-11-07 21:44:28 +08:00
BiggerRain
5292538dd7 fix: chat mode has been minimized (#968) 2025-11-07 21:05:50 +08:00
ayangweb
bab98d4576 feat: add web login (#967)
* feat: add web login

* refactor: update

* refactor: update
2025-11-07 17:12:00 +08:00
BiggerRain
6067fa7029 chore: remove check window decorations (#966)
* chore: remove check window decorations

* chore: add skip button

* chore: key add index
2025-11-05 15:13:26 +08:00
BiggerRain
61860b400f chore: set divider line (#965) 2025-11-05 09:48:50 +08:00
ayangweb
50518b6c21 refactor: optimize chat window size in compact mode (#964) 2025-11-04 20:03:53 +08:00
ayangweb
b5d3ce9910 feat: add window opacity configuration option (#963)
* feat: add window opacity configuration option

* docs: update changelog
2025-11-04 14:52:01 +08:00
ayangweb
abac92d8d5 refactor: keep the same height in compact mode (#962) 2025-11-04 11:45:47 +08:00
BiggerRain
9ea7c9b1ff chore: center the main window vertically (#959)
* chore: center the main window vertically

* docs: add release note
2025-11-03 10:52:29 +08:00
BiggerRain
fcbc77fb5a chore: fixed window background image (#960) 2025-11-03 10:52:09 +08:00
BiggerRain
60b34a118b chore: hide error messages in small window (#961) 2025-11-03 10:51:51 +08:00
SteveLauC
3e0839f3da feat(extension compatibility): minimum_coco_version (#946)
This commit introduces a new field, `minimum_coco_version`, to the
`plugin.json` JSON. It specifies the lowest Coco version required
for an extension to run.

This ensures better compatibility by preventing new extensions from
being loaded on older Coco apps that may lack necessary APIs or features.

Co-authored-by: ayang <473033518@qq.com>
2025-11-02 10:59:29 +08:00
BiggerRain
bd61faf660 fix: console code error (#956) 2025-11-02 10:58:51 +08:00
BiggerRain
0e48f4f71c fix: react code render bug (#957) 2025-11-02 10:58:23 +08:00
BiggerRain
24fe7144f8 fix: keep the window height when the popover is open. (#958) 2025-11-02 09:17:04 +08:00
BiggerRain
e92eee1ecf fix: prevent shaking when switching between chat and search pages (#955)
* fix: prevent shaking when switching between chat and search pages in compact mode

* docs: add release note
2025-10-30 10:19:05 +08:00
BiggerRain
1996298f0c feat: add shadcn ui config (#954)
* feat: add shadcn ui config

* chore: tailwind config
2025-10-29 10:38:52 +08:00
SteveLauC
c879c63b17 ci(windows): skip installing LLVM since it is pre-installed (#953)
This commit removes the CI step that installs LLVM on Windows because:

1. It was constantly failing when I worked on [1]

   ```text
   Failed in attempting to update the source: winget
   The `msstore` source requires that you view the following agreements before using.
   Terms of Transaction: https://aka.ms/microsoft-store-terms-of-transaction
   The source requires the current machine's 2-letter geographic region to be sent to
   the backend service to function properly (ex. "US").

   Failed when searching source: winget
   An unexpected error occurred while executing the command:
   0x8a15000f : Data required by the source is missing

   No packages were found among the working sources.
   ```

2. Actually, we don't need to install it since the Windows Github
   action image already includes it. See [2]

[1]: https://github.com/infinilabs/coco-app/pull/946
[2]: https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-Readme.md#language-and-runtime
2025-10-28 14:10:03 +08:00
dependabot[bot]
892fe78d03 build(deps): bump axios from 1.9.0 to 1.12.0 (#952)
Bumps [axios](https://github.com/axios/axios) from 1.9.0 to 1.12.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.9.0...v1.12.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.12.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-28 11:08:21 +08:00
ayangweb
e5860f63c7 refactor: restore default cursor movement with meta + arrow keys (#951) 2025-10-27 10:10:14 +08:00
SteveLauC
fa9656bfd7 fix: panic when opening view extension via hotkey (#949)
Another change that should be made to fix the View extension hotkey. I
should include it in this commit[1] but I forgot to.

[1]: e029ddf2ba
2025-10-27 10:08:53 +08:00
SteveLauC
03954748b6 refactor(post-search): collect at least 2 documents from each query source (#948)
This commit refactors the code that evenly collects documents from query
sources to let it collect at least 2 documents in every source, which
could correct the case when `max_hits_per_source` is 0. This was possible
with the previous impl, no longer allowed after this commit
2025-10-27 10:07:57 +08:00
ayangweb
4a627cb32e feat: add compact mode for window (#947)
* feat: add compact mode for window

* docs: update changelog

* feat: add i18n

* refactor: update

* refactor: update
2025-10-27 10:06:19 +08:00
SteveLauC
3029303e95 refactor: custom_version_comparator() now compares semantic versions (#941)
* refactor: custom_version_comparator() now compares semantic versions

Previously, when comparing 2 versions, custom_version_comparator() only
compared their build numbers, which was incorrect. See this case:

```text
0.8.0-2500 -> 0.9.0-SNAPSHOT-2501 -> 0.8.1-2502
```

Coco adopts SemVer[1], and according to the specification, "0.8.1-2502"
is older than "0.9.0-SNAPSHOT-2501" even though it has a larger build
number.

This commit refactors it to compare the semantic versions.

[1]: Even though Coco uses SemVer, our version string does not follow the
spec. In this implementation, we use `to_semver()` to do the conversion, see
the code comments for more details.

* correct comments
2025-10-27 10:04:15 +08:00
ayangweb
fc7cd165a8 refactor: replace getCurrentWindow with getCurrentWebviewWindow (#945)
* refactor: replace `getCurrentWindow` with `getCurrentWebviewWindow`

* refactor: update

* refactor: update
2025-10-23 16:48:13 +08:00
SteveLauC
f267df3f71 docs: update some comments (#944) 2025-10-23 15:23:18 +08:00
ayangweb
b07707e973 fix: allow deletion after selecting all text (#943)
* fix: allow deletion after selecting all text

* docs: update changelog
2025-10-23 14:26:08 +08:00
BiggerRain
6b0111b89f fix: remove error code (#942) 2025-10-23 09:16:18 +08:00
SteveLauC
e029ddf2ba fix(view extension): broken search bar UI when opening extensions via hotkey (#938)
* fix: open view extension via hotkey

* refactor: update

* refactor: update

* chore: check error

* chore: ci update

* release notes

---------

Co-authored-by: ayang <473033518@qq.com>
Co-authored-by: rain9 <15911122312@163.com>
2025-10-22 10:08:00 +08:00
ayangweb
731cfc5bd7 feat: allow navigate back when cursor is at the beginning (#940)
* feat: allow navigate back when cursor is at the beginning

* docs: update changelog
2025-10-21 15:58:53 +08:00
ayangweb
cbd8dc52cd feat: open quick ai with modifier key + enter (#939)
* feat: open quick ai with modifier key + enter

* docs: update changelog
2025-10-21 15:56:01 +08:00
ayangweb
d1ad1af71a refactor: hide quick ai on multi-level pages (#937) 2025-10-21 15:06:53 +08:00
ayangweb
121f9c6118 refactor: hide the search bar and filter bar on the plugin details page (#936) 2025-10-21 14:58:49 +08:00
ayangweb
770f60f30c fix: fix page rapidly flickering issue (#935)
* fix: fix page rapidly flickering issue

* docs: update changelog

* refactor: update
2025-10-21 14:31:23 +08:00
SteveLauC
5c92b5acab refactor: procedure that convert_pages() into a func (#934)
Extract a procedure that calls convert_pages() to process HTML files
into a function. It is used in both install/store.rs and
install/local_extension.rs, doing this avoids code duplication.
2025-10-20 18:23:53 +08:00
SteveLauC
8e49455acf refactor: bump tauri_nspanel & show_coco/hide_coco now use NSPanel's function on macOS (#933)
This commit:

1. Bump dep tauri_nspenel to v2.1
2. On macOS, our main window is not a window but panel. Previously, we
   were using window.show() and window.hide() in show_coco() and
   hide_coco(). In this commit, we switch from window.show/hide to
   panel.show/hide

Co-authored-by: ayang <473033518@qq.com>
2025-10-20 15:53:48 +08:00
ayangweb
859def21bf feat: standardize multi-level menu label structure (#925)
* feat: standardize multi-level menu label structure

* refactor: update

* refactor: improve tab behavior

* refactor: update

* refactor: improve backspace behavior

* refactor: optimizes tab behavior

* refactor: optimizes backspace behavior

* refactor: disable calculator subpage navigation

* refactor: iframe auto focus

* ViewExtension UI settings

* refactor: update

* fix Rust code build

* refactor: update

* refactor: update

* refactor: update

* fix tests

* support http pages directly

* support http pages directly

* docs: update changelog

* field ui can only be set by View extensions

* changelog: View Extension page field now accepts HTTP(s) links

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
2025-10-19 19:13:44 +08:00
SteveLauC
6145306ee8 chore: use a custom log directory (#930)
* chore: use a custom log directory

This commit changes our log dirctory from the one set by Tauri to
a custom one. It is mostly the same as Tauri’s one, except that
the "{bundleIdentifier}" will be "Coco AI" rather than the real
identifier.

We are doing 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.

* fmt
2025-10-19 19:13:09 +08:00
SteveLauC
d0f7b7b833 chore: allow(deprecated) to silence warnings (#931) 2025-10-19 19:12:45 +08:00
SteveLauC
f221606ae2 ci: fix fontend-ci.yml syntax error (#932)
* ci: fix fontend-ci.yml syntax error

* more to fix

* remove file foo
2025-10-19 16:35:37 +08:00
SteveLauC
cd00ada3ac feat: return sub-exts when extension type exts themselves are matched (#928)
Take the 'Spotify Control' extension as an example:

- Spotify Control
  - Toggle Play/Pause
  - Next Track
  - Previous Track

Previously, these sub-extensions were only returned when the query string
matched them, and thus counterintuitively, searching for 'Spotify Control' would
not hit them.

This commit changes that behavior: when a main extension (of type Extension)
matches the query, all of its sub-extensions are now included in the results.
2025-10-19 09:58:01 +08:00
SteveLauC
be6611133a refactor(calculator): skip evaluation if expr is in form "num => num" (#929)
When the expression to evaluate contains only a number, the result is
guaranteed to be this number. In this case, we no longer evaluate the
expression as telling users that "x = x" is meaningless.
2025-10-17 15:56:06 +08:00
BiggerRain
9e682ceafc ci: add ci detection for web component packaging (#927)
* build: add web build ci

* chore: remove  test code

* Update .github/workflows/frontend-ci.yml

Co-authored-by: SteveLauC <stevelauc@outlook.com>

---------

Co-authored-by: SteveLauC <stevelauc@outlook.com>
2025-10-16 08:58:04 +08:00
SteveLauC
5510bedf7f refactor: retry if AXUIElementSetAttributeValue() does not work (#924)
Found another case where the `NextDisplay` command does not work (I said
another because the bug that commit ca71f07f3a3cc[1] fixed was also found
by playing with the `NextDisplay` command). After debugging, the root cause
of the issue is that the macOS API `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.

I don't know why it does not work so the only thing we can do is to retry.
Luckily, retrying works, at least in my tests.

[1]: ca71f07f3a
2025-10-15 10:22:58 +08:00
SteveLauC
ea34b7a404 fix(Window Management): Next/Previous Desktop do not work (#926)
This commit fixes the issue that when the current desktop contains more
than 1 windows, moving the focused window via `NextDesktop` and
`PreviousDesktop` won't work.

How? By adding 2 missing `sleep()` functions:

1. b91a18dbb8/Silica/Sources/SIWindow.m (L242)
2. b91a18dbb8/Silica/Sources/SIWindow.m (L249)

Also, this commit improves the implementation by resetting the mouse position.
`NextDesktop` and `PreviousDesktop` are implemented by emulating mouse and
keyboard events, draging the focused window and switching to the corresponding
desktop. To make a window draggable, we have to move the mouse to the window's
traffic light area. It is disturbing to not move the mouse back so this commit
implements it.
2025-10-14 17:39:14 +08:00
BiggerRain
ce94543baa build: remove tauri from web component build (#923) 2025-10-14 15:42:51 +08:00
BiggerRain
89a8304b9e build: build error (#922) 2025-10-13 16:43:29 +08:00
BiggerRain
9652a54f08 chore: add cross-domain configuration for web component (#921)
* chore: add cross-domain configuration for web component

* docs: add release note
2025-10-13 15:45:28 +08:00
SteveLauC
ca71f07f3a fix: WM ext does not work when operating focused win from another display (#919)
This commit fixes a bug that most Window Management extension commands
won't work if you:

1. operate the focused window from another display
2. and they are adjacent

To reproduce this:

say you have 2 displays

1. Put the focused window on a non-main display, maximize the window
2. Move the mourse to the main display, making it the active display
3. Launch Coco, then execute the `TopHalf` command

The focused window will be moved to the main display, while it should
stay in the non-main display.

The root cause of the issue is that the previous implementation of
`intersects()` didn't handle an edge case correctly, adjavent rectangles
should not be considered overlapping. This commit replaces the buggy
implementation with the `CGRectIntersectsRect()` function from macOS
core graphics library.
2025-10-13 11:13:42 +08:00
BiggerRain
00eb6bed2b feat: support pageup/pagedown to navigate search results (#920)
* feat: support pageup/pagedown to navigate search results

* docs: add release note
2025-10-11 17:08:53 +08:00
BiggerRain
95dc7a88d2 feat: support moving cursor with home and end keys (#918)
* feat: support moving cursor with home and end keys

* docs: add release notes
2025-10-11 15:38:15 +08:00
ayangweb
6aec9cbae2 fix: resolve pinned window shortcut not working (#917)
* fix: fix pinned window shortcut not working

* docs: update changelog

* refactor: update

* Update _index.md

---------

Co-authored-by: Medcl <m@medcl.net>
2025-10-11 14:46:24 +08:00
BiggerRain
4e58bc4b2c fix: duplicate chat content (#916)
* style: add dark drop shadow to images

* docs: add release note

* style: add dark

* fix: duplicate chat content

* docs: add release note

* chore: update history list
2025-10-11 10:33:09 +08:00
ayangweb
a9a4b5319c feat: support opening logs from about page (#915)
* feat: support opening logs from about page

* docs: update changelog

* refactor: update

* refactor: update i18n
2025-10-10 15:16:46 +08:00
SteveLauC
6523fef12b chore: correct link to Coco server docs (#914) 2025-10-10 14:48:05 +08:00
ayangweb
b8affcd4a1 refactor: improve sorting logic of search results (#910)
* refactor: improve sorting logic of search results

* refactor: update

* wip

* feat: support switching groups via keyboard shortcuts (#911)

* feat: support switching groups via keyboard shortcuts

* refactor: update

* docs: update changelog

* refactor post-querying logic

* refactor post-querying logic

* refactor post-querying logic

* refactor post-querying logic

* refactor: refactoring rerank function

* refactor: refactoring rerank with intelligent hybrid scorer

* chore: remove debug logging

* chore: fix format

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
Co-authored-by: medcl <m@medcl.net>
2025-10-10 14:32:44 +08:00
BiggerRain
595ae676b7 style: add dark drop shadow to images (#912)
* style: add dark drop shadow to images

* docs: add release note

* style: add dark

* style: remove dark
2025-10-10 14:28:23 +08:00
BiggerRain
5c76c92c95 fix: automatic update of service list (#913)
* fix: automatic update of service list

* docs: add release note
2025-10-10 14:26:59 +08:00
ayangweb
f03ad8a6c8 feat: support switching groups via keyboard shortcuts (#911)
* feat: support switching groups via keyboard shortcuts

* refactor: update

* docs: update changelog
2025-10-09 16:50:13 +08:00
139 changed files with 5330 additions and 2722 deletions

View File

@@ -5,6 +5,8 @@ on:
# Only run it when Frontend code changes
paths:
- 'src/**'
- 'tsup.config.ts'
- 'package.json'
jobs:
check:
@@ -17,6 +19,9 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -30,5 +35,36 @@ jobs:
- name: Install dependencies
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
run: pnpm build

View File

@@ -110,10 +110,10 @@ jobs:
# On Windows, we need to generate bindings for 'searchapi.h' using bindgen.
# And bindgen relies on 'libclang'
# https://rust-lang.github.io/rust-bindgen/requirements.html#windows
- name: Install dependencies (Windows only)
if: startsWith(matrix.platform, 'windows-latest')
shell: bash
run: winget install LLVM.LLVM --silent --accept-package-agreements --accept-source-agreements
#
# We don't need to install it because it is already included in GitHub
# Action runner image:
# https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-Readme.md#language-and-runtime
- name: Add Rust build target

View File

@@ -35,10 +35,10 @@ jobs:
# On Windows, we need to generate bindings for 'searchapi.h' using bindgen.
# And bindgen relies on 'libclang'
# https://rust-lang.github.io/rust-bindgen/requirements.html#windows
- name: Install dependencies (Windows only)
if: startsWith(matrix.platform, 'windows-latest')
shell: bash
run: winget install LLVM.LLVM --silent --accept-package-agreements --accept-source-agreements
#
# We don't need to install it because it is already included in GitHub
# Action runner image:
# https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-Readme.md#language-and-runtime
- name: Add pizza engine as a dependency
working-directory: src-tauri

22
components.json Normal file
View 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": {}
}

View File

@@ -9,7 +9,7 @@ Coco AI is a fully open-source, cross-platform unified search and productivity t
{{% 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

View File

@@ -8,11 +8,68 @@ title: "Release Notes"
Information about release notes of Coco App is provided here.
## Latest (In development)
### ❌ Breaking changes
### 🚀 Features
### 🐛 Bug fix
- fix: search_extension should not panic when ext is not found #983
- fix: persist configuration settings properly #987
### ✈️ 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)

View File

@@ -1,7 +1,7 @@
{
"name": "coco",
"private": true,
"version": "0.8.0",
"version": "0.9.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -19,6 +19,7 @@
},
"dependencies": {
"@headlessui/react": "^2.2.2",
"@radix-ui/react-slot": "^1.2.3",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-autostart": "~2.2.0",
"@tauri-apps/plugin-deep-link": "^2.2.1",
@@ -34,7 +35,8 @@
"@tauri-apps/plugin-window": "2.0.0-alpha.1",
"@wavesurfer/react": "^1.0.11",
"ahooks": "^3.8.4",
"axios": "^1.9.0",
"axios": "^1.12.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"dotenv": "^16.5.0",
@@ -59,6 +61,7 @@
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"tauri-plugin-fs-pro-api": "^2.4.0",
"tauri-plugin-macos-permissions-api": "^2.3.0",
"tauri-plugin-screenshots-api": "^2.2.0",

1079
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

394
src-tauri/Cargo.lock generated
View File

@@ -2,6 +2,212 @@
# It is not intended for manual editing.
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]]
name = "addr2line"
version = "0.24.2"
@@ -668,6 +874,15 @@ dependencies = [
"serde",
]
[[package]]
name = "bytestring"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289"
dependencies = [
"bytes",
]
[[package]]
name = "bzip2"
version = "0.6.0"
@@ -869,8 +1084,10 @@ dependencies = [
[[package]]
name = "coco"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"actix-files",
"actix-web",
"anyhow",
"applications",
"async-recursion",
@@ -976,7 +1193,7 @@ dependencies = [
"block",
"cocoa-foundation",
"core-foundation 0.10.1",
"core-graphics 0.24.0",
"core-graphics",
"foreign-types 0.5.0",
"libc",
"objc",
@@ -1058,6 +1275,17 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "cookie"
version = "0.18.1"
@@ -1075,7 +1303,7 @@ version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9"
dependencies = [
"cookie",
"cookie 0.18.1",
"document-features",
"idna",
"log",
@@ -1126,19 +1354,6 @@ dependencies = [
"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]]
name = "core-graphics-types"
version = "0.2.0"
@@ -1561,7 +1776,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67fd9ae1736d6ebb2e472740fbee86fb2178b8d56feb98a6751411d4c95b7e72"
dependencies = [
"cocoa",
"core-graphics 0.24.0",
"core-graphics",
"dunce",
"gdk",
"gdkx11",
@@ -1656,7 +1871,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cf6f550bbbdd5fe66f39d429cb2604bcdacbf00dca0f5bbe2e9306a0009b7c6"
dependencies = [
"core-foundation 0.10.1",
"core-graphics 0.24.0",
"core-graphics",
"foreign-types-shared 0.3.1",
"libc",
"log",
@@ -1912,6 +2127,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
@@ -2662,6 +2883,25 @@ dependencies = [
"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]]
name = "h2"
version = "0.4.12"
@@ -2892,7 +3132,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"h2 0.4.12",
"http 1.3.1",
"http-body 1.0.1",
"httparse",
@@ -2955,7 +3195,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"socket2 0.6.0",
"system-configuration",
"tokio",
"tower-service",
@@ -3150,6 +3390,12 @@ version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408"
[[package]]
name = "impl-more"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2"
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -3451,6 +3697,12 @@ dependencies = [
"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]]
name = "lazy_static"
version = "1.5.0"
@@ -3610,6 +3862,23 @@ dependencies = [
"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]]
name = "lock_api"
version = "0.4.13"
@@ -4126,17 +4395,6 @@ dependencies = [
"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]]
name = "objc-sys"
version = "0.3.5"
@@ -4479,15 +4737,6 @@ dependencies = [
"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]]
name = "object"
version = "0.36.7"
@@ -4709,6 +4958,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "path-clean"
version = "1.0.1"
@@ -5214,7 +5469,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"socket2 0.6.0",
"thiserror 2.0.16",
"tokio",
"tracing",
@@ -5251,7 +5506,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"socket2 0.6.0",
"tracing",
"windows-sys 0.60.2",
]
@@ -5537,6 +5792,12 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "regex-lite"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da"
[[package]]
name = "regex-syntax"
version = "0.8.6"
@@ -5560,12 +5821,12 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
dependencies = [
"base64 0.22.1",
"bytes",
"cookie",
"cookie 0.18.1",
"cookie_store",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"h2 0.4.12",
"http 1.3.1",
"http-body 1.0.1",
"http-body-util",
@@ -6261,6 +6522,16 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "socket2"
version = "0.6.0"
@@ -6279,7 +6550,7 @@ checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08"
dependencies = [
"bytemuck",
"cfg_aliases",
"core-graphics 0.24.0",
"core-graphics",
"foreign-types 0.5.0",
"js-sys",
"log",
@@ -6527,7 +6798,7 @@ dependencies = [
"bitflags 2.9.4",
"block2 0.6.1",
"core-foundation 0.10.1",
"core-graphics 0.24.0",
"core-graphics",
"crossbeam-channel",
"dispatch",
"dlopen2",
@@ -6606,7 +6877,7 @@ checksum = "d4d1d3b3dc4c101ac989fd7db77e045cc6d91a25349cd410455cb5c57d510c1c"
dependencies = [
"anyhow",
"bytes",
"cookie",
"cookie 0.18.1",
"dirs 6.0.0",
"dunce",
"embed_plist",
@@ -6727,17 +6998,13 @@ dependencies = [
[[package]]
name = "tauri-nspanel"
version = "2.0.1"
source = "git+https://github.com/ahkohd/tauri-nspanel?branch=v2#18ffb9a201fbf6fedfaa382fd4b92315ea30ab1a"
version = "2.1.0"
source = "git+https://github.com/ahkohd/tauri-nspanel?branch=v2.1#da9c9a8d4eb7f0524a2508988df1a7d9585b4904"
dependencies = [
"bitflags 2.9.4",
"block",
"cocoa",
"core-foundation 0.10.1",
"core-graphics 0.25.0",
"objc",
"objc-foundation",
"objc_id",
"objc2 0.6.2",
"objc2-app-kit 0.3.1",
"objc2-foundation 0.3.1",
"pastey",
"tauri",
]
@@ -7122,7 +7389,7 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4cfc9ad45b487d3fded5a4731a567872a4812e9552e3964161b08edabf93846"
dependencies = [
"cookie",
"cookie 0.18.1",
"dpi",
"gtk",
"http 1.3.1",
@@ -7376,7 +7643,7 @@ dependencies = [
"pin-project-lite",
"signal-hook-registry",
"slab",
"socket2",
"socket2 0.6.0",
"tokio-macros",
"tracing",
"windows-sys 0.59.0",
@@ -7598,6 +7865,7 @@ version = "0.1.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -7886,6 +8154,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "v_htmlescape"
version = "0.15.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"
[[package]]
name = "value-bag"
version = "1.11.1"
@@ -9009,7 +9283,7 @@ checksum = "31f0e9642a0d061f6236c54ccae64c2722a7879ad4ec7dff59bd376d446d8e90"
dependencies = [
"base64 0.22.1",
"block2 0.6.1",
"cookie",
"cookie 0.18.1",
"crossbeam-channel",
"dirs 6.0.0",
"dpi",

View File

@@ -1,6 +1,6 @@
[package]
name = "coco"
version = "0.8.0"
version = "0.9.0"
description = "Search, connect, collaborate all in one place."
authors = ["INFINI Labs"]
edition = "2024"
@@ -117,12 +117,14 @@ urlencoding = "2.1.3"
scraper = "0.17"
toml = "0.8"
path-clean = "1.0.1"
actix-files = "0.6.8"
actix-web = "4.11.0"
[dev-dependencies]
tempfile = "3.23.0"
[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 = "0.6.2"
objc2-core-foundation = {version = "0.3.1", features = ["CFString", "CFCGTypes", "CFArray"] }

View File

@@ -1,7 +1,10 @@
#[cfg(target_os = "macos")]
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_json::Value as Json;
use std::collections::HashMap;
use tauri::{AppHandle, Emitter};
@@ -85,10 +88,15 @@ pub(crate) enum ExtensionOnOpenedType {
open_with: Option<String>,
},
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.
///
/// It should be an absolute path or Tauri cannot open it.
page: String,
ui: Option<ViewExtensionUISettings>,
},
}
@@ -118,7 +126,12 @@ impl OnOpened {
// 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".
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.
String::from("N/A")
}
@@ -132,7 +145,7 @@ impl OnOpened {
pub(crate) async fn open(
tauri_app_handle: AppHandle,
on_opened: OnOpened,
extra_args: Option<HashMap<String, String>>,
extra_args: Option<HashMap<String, Json>>,
) -> Result<(), String> {
use crate::util::open as homemade_tauri_shell_open;
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.
*
* Payload `page_and_permission` contains the information needed
* Payload `view_extension_opened` contains the information needed
* to do that.
*
* See "src/pages/main/index.tsx" for more info.
*/
use camino::Utf8Path;
use serde_json::Value as Json;
use serde_json::to_value;
let page_and_permission: [Json; 2] =
[Json::String(page), to_value(permission).unwrap()];
let html_filename = page_path
.file_name()
.unwrap_or_else(|| {
panic!("View extension page path should have a file name, but [{}] does not have one", page);
}).to_string();
url.push('/');
url.push_str(&html_filename);
let html_file_url = url;
debug!("View extension listening on: {}", html_file_url);
let view_extension_opened: [Json; 5] = [
Json::String(name),
Json::String(icon),
Json::String(html_file_url),
to_value(permission).unwrap(),
to_value(ui).unwrap(),
];
tauri_app_handle
.emit("open_view_extension", page_and_permission)
.emit("open_view_extension", view_extension_opened)
.unwrap();
}
}

View File

@@ -100,7 +100,7 @@ impl SearchQuery {
}
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Hash, PartialEq, Eq)]
pub struct QuerySource {
pub r#type: String, //coco-server/local/ etc.
pub id: String, //coco server's id

View File

@@ -1227,6 +1227,7 @@ pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>,
name,
platforms: None,
developer: None,
minimum_coco_version: None,
// Leave it empty as it won't be used
description: String::new(),
icon: icon_path,
@@ -1242,6 +1243,7 @@ pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>,
enabled,
settings: None,
page: None,
ui: None,
permission: None,
screenshots: None,
url: None,

View File

@@ -138,7 +138,7 @@ impl SearchSource for CalculatorSource {
// will only be evaluated against non-whitespace characters.
let query_string = query_string.trim();
if query_string.is_empty() || query_string.len() == 1 {
if query_string.is_empty() {
return Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
@@ -150,6 +150,26 @@ impl SearchSource for CalculatorSource {
let query_source = self.get_type();
let base_score = self.base_score;
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);
match res_num {

View File

@@ -8,6 +8,7 @@ use std::ffi::c_ushort;
use std::ffi::c_void;
use std::ops::Deref;
use std::ptr::NonNull;
use std::time::Duration;
use objc2::MainThreadMarker;
use objc2_app_kit::NSEvent;
@@ -34,6 +35,7 @@ use objc2_core_graphics::CGEventType;
use objc2_core_graphics::CGMouseButton;
use objc2_core_graphics::CGRectGetMidX;
use objc2_core_graphics::CGRectGetMinY;
use objc2_core_graphics::CGRectIntersectsRect;
use objc2_core_graphics::CGWindowID;
use super::error::Error;
@@ -46,12 +48,7 @@ use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
fn intersects(r1: CGRect, r2: CGRect) -> bool {
let overlapping = !(r1.origin.x + r1.size.width < r2.origin.x
|| 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
unsafe { CGRectIntersectsRect(r1, r2) }
}
/// 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)
}
/// 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.
fn get_ui_element_size(ui_element: &CFRetained<AXUIElement>) -> Result<CGSize, Error> {
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)
}
/// 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).
fn get_frontmost_window() -> Result<CFRetained<AXUIElement>, Error> {
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 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(
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());
}
// 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]
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 {
// Let go of the window.
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(())
@@ -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
/// 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
/// is currently safe to draw your apps 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
/// 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> {
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 mut point = frame.origin;
let ptr_to_point = NonNull::new((&mut point as *mut CGPoint).cast::<c_void>()).unwrap();
let pos_value = unsafe { AXValue::new(AXValueType::CGPoint, ptr_to_point) }.unwrap();
let pos_attr = CFString::from_static_str("AXPosition");
/*
* Set window origin
*/
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()) };
if error != AXError::Success {
return Err(Error::AXError(error));
let current = get_ui_element_origin(&frontmost_window)?;
if current == frame.origin {
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();
let size_value = unsafe { AXValue::new(AXValueType::CGSize, ptr_to_size) }.unwrap();
let size_attr = CFString::from_static_str("AXSize");
/*
* Set window size
*/
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()) };
if error != AXError::Success {
return Err(Error::AXError(error));
let current = get_ui_element_size(&frontmost_window)?;
// For size, we do not check if `current` has the exact same value as
// `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(())
@@ -624,6 +720,15 @@ pub fn toggle_fullscreen() -> Result<(), Error> {
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>>> =
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();
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");
}
}

View File

@@ -28,6 +28,7 @@ use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_global_shortcut::ShortcutState;
pub(crate) const EXTENSION_ID: &str = "Window Management";
pub(crate) const EXTENSION_NAME_LOWERCASE: &str = "window management";
/// JSON file for this extension.
pub(crate) const PLUGIN_JSON_FILE: &str = include_str!("./plugin.json");

View File

@@ -1,4 +1,5 @@
use super::EXTENSION_ID;
use super::EXTENSION_NAME_LOWERCASE;
use crate::common::document::{DataSourceReference, Document};
use crate::common::{
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
};

View File

@@ -1,22 +1,27 @@
pub(crate) mod api;
pub(crate) mod built_in;
pub(crate) mod third_party;
pub(crate) mod view_extension;
use crate::common::document::ExtensionOnOpened;
use crate::common::document::ExtensionOnOpenedType;
use crate::common::document::OnOpened;
use crate::common::register::SearchSourceRegistry;
use crate::util::platform::Platform;
use crate::util::version::COCO_VERSION;
use crate::util::version::parse_coco_semver;
use anyhow::Context;
use bitflags::bitflags;
use borrowme::{Borrow, ToOwned};
use derive_more::Display;
use indexmap::IndexMap;
use semver::Version as SemVer;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as Json;
use std::collections::HashMap;
use std::collections::HashSet;
use std::ops::Deref;
use std::path::Path;
use tauri::{AppHandle, Manager};
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";
const PLUGIN_JSON_FILE_NAME: &str = "plugin.json";
const ASSETS_DIRECTORY_FILE_NAME: &str = "assets";
const PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION: &str = "minimum_coco_version";
fn default_true() -> bool {
true
@@ -39,10 +45,9 @@ pub struct Extension {
name: String,
/// ID of the developer.
///
/// * For built-in extensions, this will always be None.
/// * For third-party first-layer extensions, the on-disk plugin.json file
/// 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 will be None.
/// * For built-in extensions, this is None.
/// * For third-party main extensions, this field contains the extension developer ID.
/// * For third-party sub extensions, this field is be None.
developer: Option<String>,
/// 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
/// 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>,
ui: Option<ViewExtensionUISettings>,
/// Permission that this extension requires.
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
* extension store.
@@ -126,6 +146,17 @@ pub struct Extension {
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.
#[derive(Debug, Deserialize, Serialize, PartialEq, Clone)]
pub(crate) struct ExtensionBundleId {
@@ -262,11 +293,19 @@ impl Extension {
ExtensionType::Script => todo!("not supported yet"),
ExtensionType::Setting => todo!("not supported yet"),
ExtensionType::View => {
let name = self.name.clone();
let icon = self.icon.clone();
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);
}).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 {
ty: extension_on_opened_type,
settings,
@@ -276,6 +315,9 @@ impl Extension {
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)]
pub(crate) struct CommandAction {
pub(crate) exec: String,
@@ -416,7 +478,7 @@ impl QuicklinkLink {
/// if any.
pub(crate) fn concatenate_url(
&self,
user_supplied_args: &Option<HashMap<String, String>>,
user_supplied_args: &Option<HashMap<String, Json>>,
) -> String {
let mut out = String::new();
for component in self.components.iter() {
@@ -428,20 +490,23 @@ impl QuicklinkLink {
argument_name,
default,
} => {
let opt_argument_value = {
let opt_argument_value: Option<&str> = {
let user_supplied_arg = user_supplied_args
.as_ref()
.and_then(|map| map.get(argument_name.as_str()));
if user_supplied_arg.is_some() {
user_supplied_arg
user_supplied_arg.map(|json| {
json.as_str()
.expect("quicklink should provide string arguments")
})
} else {
default.as_ref()
default.as_deref()
}
};
let argument_value_str = match opt_argument_value {
Some(str) => str.as_str(),
Some(str) => str,
// None => an empty string
None => "",
};
@@ -550,6 +615,10 @@ pub enum ExtensionType {
AiExtension,
#[display("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 {
@@ -797,6 +866,22 @@ pub(crate) async fn init_extensions(tauri_app_handle: &AppHandle) -> Result<(),
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]
pub(crate) async fn enable_extension(
tauri_app_handle: AppHandle,
@@ -904,6 +989,13 @@ pub(crate) fn canonicalize_relative_icon_path(
let icon_path = Path::new(icon_str);
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 mut assets_directory = extension_dir.join(ASSETS_DIRECTORY_FILE_NAME);
assets_directory.push(icon_path);
@@ -963,6 +1055,14 @@ pub(crate) fn canonicalize_relative_page_path(
.page
.as_ref()
.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);
if page_path.is_relative() {
@@ -1741,7 +1841,7 @@ mod tests {
],
};
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));
assert_eq!(result, "https://www.google.com/search?q=");
}
@@ -1778,7 +1878,7 @@ mod tests {
],
};
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));
assert_eq!(result, "https://www.google.com/search?q=rust");
}
@@ -1801,7 +1901,7 @@ mod tests {
],
};
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));
assert_eq!(result, "https://www.google.com/search?q=python");
}

View File

@@ -14,6 +14,7 @@
use crate::extension::Extension;
use crate::extension::ExtensionType;
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
use crate::util::platform::Platform;
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(())
}
@@ -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(())
}
@@ -267,8 +283,10 @@ mod tests {
hotkey: None,
enabled: true,
page,
ui: None,
permission: None,
settings: None,
minimum_coco_version: None,
screenshots: None,
url: None,
version: None,
@@ -532,6 +550,21 @@ mod tests {
"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]

View File

@@ -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::install::{
convert_page, filter_out_incompatible_sub_extensions, is_extension_installed,
filter_out_incompatible_sub_extensions, is_extension_installed,
};
use crate::extension::third_party::{
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE, get_third_party_extension_directory,
@@ -8,7 +10,6 @@ use crate::extension::third_party::{
use crate::extension::{
Extension, canonicalize_relative_icon_path, canonicalize_relative_page_path,
};
use crate::extension::{ExtensionType, PLUGIN_JSON_FILE_NAME};
use crate::util::platform::Platform;
use serde_json::Value as Json;
use std::path::Path;
@@ -79,6 +80,10 @@ pub(crate) async fn install_local_extension(
let mut extension_json: Json =
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
let extension_obj = extension_json
.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
// structured error type
return Err("incompatible".into());
return Err("platform_incompatible".into());
}
}
/* Check ends here */
@@ -221,53 +226,6 @@ pub(crate) async fn install_local_extension(
.await
.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_path(&dest_dir, &mut extension)?;
canonicalize_relative_page_path(&dest_dir, &mut extension)?;

View File

@@ -4,19 +4,27 @@
//! # How
//!
//! 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
//!
//! 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
//! 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
//! 2. Platform compatibility check
//!
//! 4. Write the extension files to the corresponding location
//! 5. Write the extension files to the corresponding location
//!
//! * developer directory
//! * extension directory
@@ -25,11 +33,6 @@
//! * plugin.json file
//! * 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
//! relative paths
//!
@@ -42,8 +45,11 @@ pub(crate) mod local_extension;
pub(crate) mod store;
use crate::extension::Extension;
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
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;
@@ -115,117 +121,31 @@ pub(crate) fn filter_out_incompatible_sub_extensions(
}
}
/// Convert the page file to make it loadable by the Tauri/Webview.
pub(crate) async fn convert_page(absolute_page_path: &Path) -> Result<(), String> {
assert!(absolute_page_path.is_absolute());
let page_content = tokio::fs::read_to_string(absolute_page_path)
.await
.map_err(|e| e.to_string())?;
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(())
/// Inspect the "minimum_coco_version" field and see if this extension is
/// compatible with the current Coco app.
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 {
return Ok(true);
};
if mcv_json == &Json::Null {
return Ok(true);
}
/// 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 Some(mcv_str) = mcv_json.as_str() else {
return Err(format!(
"invalid extension: field [{}] should be a string",
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
));
};
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);
let Some(mcv) = parse_coco_semver(mcv_str) else {
return Err(format!(
"invalid extension: [{}] is not a valid version string",
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
));
};
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
.parent()
.ok_or_else(|| format!("page path is invalid, it should have a parent path"))?;
let document: Html = Html::parse_document(page_content);
let mut modified_html: String = page_content.to_string();
modify_tag_attributes(
&document,
&mut modified_html,
base_dir,
"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)]
@@ -259,6 +179,8 @@ mod tests {
enabled: true,
settings: None,
page: None,
ui: None,
minimum_coco_version: None,
permission: None,
screenshots: None,
url: None,
@@ -428,257 +350,4 @@ mod tests {
filter_out_incompatible_sub_extensions(&mut main_extension, Platform::Macos);
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);
}
}

View File

@@ -1,6 +1,7 @@
//! Extension store related stuff.
use super::super::LOCAL_QUERY_SOURCE_TYPE;
use super::check_compatibility_via_mcv;
use super::is_extension_installed;
use crate::common::document::DataSourceReference;
use crate::common::document::Document;
@@ -10,14 +11,12 @@ use crate::common::search::QuerySource;
use crate::common::search::SearchQuery;
use crate::common::traits::SearchSource;
use crate::extension::Extension;
use crate::extension::ExtensionType;
use crate::extension::PLUGIN_JSON_FILE_NAME;
use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
use crate::extension::canonicalize_relative_icon_path;
use crate::extension::canonicalize_relative_page_path;
use crate::extension::third_party::check::general_check;
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::server::http_client::HttpClient;
use crate::util::platform::Platform;
@@ -26,8 +25,6 @@ use reqwest::StatusCode;
use serde_json::Map as JsonObject;
use serde_json::Value as Json;
use std::io::Read;
use std::path::Path;
use std::path::PathBuf;
use tauri::AppHandle;
const DATA_SOURCE_ID: &str = "Extension Store";
@@ -107,15 +104,23 @@ pub(crate) async fn search_extension(
.await
.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
let mut response: JsonObject<String, Json> = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {:?}", e))?;
let hits_json = response
.remove("hits")
.expect("the JSON response should contain field [hits]");
let hits_json = response.remove("hits").unwrap_or_else(|| {
panic!(
"the JSON response should contain field [hits], response [{:?}]",
response
)
});
let mut hits = match hits_json {
Json::Object(obj) => obj,
_ => panic!(
@@ -262,6 +267,10 @@ pub(crate) async fn install_extension_from_store(
let mut extension: Json = serde_json::from_str(&plugin_json_content)
.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
.as_object_mut()
.expect("plugin.json should be an object")
@@ -311,7 +320,7 @@ pub(crate) async fn install_extension_from_store(
let current_platform = Platform::current();
if let Some(ref platforms) = extension.platforms {
if !platforms.contains(&current_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
.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_path(&extension_directory, &mut extension)?;
canonicalize_relative_page_path(&extension_directory, &mut extension)?;

View File

@@ -15,14 +15,22 @@ use crate::common::search::QuerySource;
use crate::common::search::SearchQuery;
use crate::common::traits::SearchSource;
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::canonicalize_relative_page_path;
use crate::extension::is_extension_compatible;
use crate::util::platform::Platform;
use crate::util::version::COCO_VERSION;
use crate::util::version::parse_coco_semver;
use async_trait::async_trait;
use borrowme::ToOwned;
use check::general_check;
use function_name::named;
use semver::Version as SemVer;
use serde_json::Value as Json;
use std::io::ErrorKind;
use std::ops::Deref;
use std::path::Path;
use std::path::PathBuf;
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)
.await
.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) {
Ok(extension) => extension,
Err(e) => {
@@ -246,7 +402,6 @@ impl ThirdPartyExtensionsSearchSource {
if extension.supports_alias_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 extension_id_clone = extension.id.clone();
tauri_app_handle
@@ -755,13 +910,28 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
let extensions_read_lock =
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() {
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 {
for command in commands.iter().filter(|cmd| cmd.enabled) {
if let Some(hit) =
extension_to_hit(command, &query_lower, opt_data_source.as_deref())
{
if let Some(hit) = extension_to_hit(
command,
&query_lower,
opt_data_source.as_deref(),
opt_main_extension_lowercase_name.as_deref(),
) {
hits.push(hit);
}
}
@@ -769,9 +939,12 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
if let Some(ref scripts) = extension.scripts {
for script in scripts.iter().filter(|script| script.enabled) {
if let Some(hit) =
extension_to_hit(script, &query_lower, opt_data_source.as_deref())
{
if let Some(hit) = extension_to_hit(
script,
&query_lower,
opt_data_source.as_deref(),
opt_main_extension_lowercase_name.as_deref(),
) {
hits.push(hit);
}
}
@@ -783,6 +956,7 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
quicklink,
&query_lower,
opt_data_source.as_deref(),
opt_main_extension_lowercase_name.as_deref(),
) {
hits.push(hit);
}
@@ -790,17 +964,20 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
}
if let Some(ref views) = extension.views {
for view in views.iter().filter(|link| link.enabled) {
if let Some(hit) =
extension_to_hit(view, &query_lower, opt_data_source.as_deref())
{
for view in views.iter().filter(|view| view.enabled) {
if let Some(hit) = extension_to_hit(
view,
&query_lower,
opt_data_source.as_deref(),
opt_main_extension_lowercase_name.as_deref(),
) {
hits.push(hit);
}
}
}
} else {
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);
}
@@ -839,10 +1016,18 @@ pub(crate) async fn uninstall_extension(
.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(
extension: &Extension,
query_lower: &str,
opt_data_source: Option<&str>,
opt_main_extension_lowercase_name: Option<&str>,
) -> Option<(Document, f64)> {
if !extension.searchable() {
return None;
@@ -865,14 +1050,26 @@ pub(crate) fn extension_to_hit(
if let Some(title_score) =
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
// Alias is considered less important than title, so it gets a lower weight.
if let Some(alias) = &extension.alias {
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;
}
}

View 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)
}

View File

@@ -7,22 +7,26 @@ mod server;
mod settings;
mod setup;
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::{CHECK_WINDOW_LABEL, MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL};
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 autostart::change_autostart;
use lazy_static::lazy_static;
use std::sync::Mutex;
use std::sync::OnceLock;
use tauri::plugin::TauriPlugin;
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, WebviewWindow, WindowEvent};
use tauri::{
AppHandle, Emitter, LogicalPosition, Manager, PhysicalPosition, WebviewWindow, WindowEvent,
};
use tauri_plugin_autostart::MacosLauncher;
/// Tauri store name
pub(crate) const COCO_TAURI_STORE: &str = "coco_tauri_store";
pub(crate) const WINDOW_CENTER_BASELINE_HEIGHT: i32 = 590;
lazy_static! {
static ref PREVIOUS_MONITOR_NAME: Mutex<Option<String>> = Mutex::new(None);
@@ -44,6 +48,26 @@ async fn change_window_height(handle: AppHandle, height: u32) {
let mut size = window.outer_size().unwrap();
size.height = height;
window.set_size(size).unwrap();
// Center the window horizontally and vertically based on the baseline height of 590
let monitor = window.primary_monitor().ok().flatten().or_else(|| {
window
.available_monitors()
.ok()
.and_then(|ms| ms.into_iter().next())
});
if let Some(monitor) = monitor {
let monitor_position = monitor.position();
let monitor_size = monitor.size();
let 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)]
@@ -89,7 +113,7 @@ pub fn run() {
.plugin(tauri_plugin_process::init())
.plugin(
tauri_plugin_updater::Builder::new()
.default_version_comparator(crate::util::updater::custom_version_comparator)
.default_version_comparator(crate::util::version::custom_version_comparator)
.build(),
)
.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::local_extension::install_local_extension,
extension::third_party::uninstall_extension,
extension::is_extension_compatible,
extension::api::apis,
extension::api::fs::read_dir,
settings::set_allow_self_signature,
settings::get_allow_self_signature,
settings::set_local_query_source_weight,
settings::get_local_query_source_weight,
assistant::ask_ai,
crate::common::document::open,
extension::built_in::file_search::config::get_file_system_config,
@@ -179,6 +206,7 @@ pub fn run() {
setup::backend_setup,
util::app_lang::update_app_lang,
util::path::path_absolute,
util::logging::app_log_dir
])
.setup(|app| {
#[cfg(target_os = "macos")]
@@ -265,9 +293,20 @@ async fn show_coco(app_handle: AppHandle) {
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
move_window_to_active_monitor(&window);
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
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).unwrap();
panel.show_and_make_key();
}).unwrap();
} else {
let _ = window.show();
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
@@ -276,82 +315,51 @@ async fn show_coco(app_handle: AppHandle) {
// 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.
#[cfg(not(target_os = "macos"))]
let _ = window.set_focus();
}
};
let _ = app_handle.emit("show-coco", ());
}
}
#[tauri::command]
async fn hide_coco(app: AppHandle) {
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
async fn hide_coco(app_handle: AppHandle) {
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
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 {
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) {
//dbg!("Moving window to active monitor");
// 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;
}
};
let scale_factor = window.scale_factor().unwrap();
// Attempt to get the cursor position, handle failure gracefully
let cursor_position = match window.cursor_position() {
Ok(pos) => Some(pos),
Err(e) => {
log::error!("Failed to get cursor position: {}", e);
None
}
};
let point = window.cursor_position().unwrap();
// Find the monitor that contains the cursor or default to the primary monitor
let target_monitor = if let Some(cursor_position) = cursor_position {
// Convert cursor position to integers
let cursor_x = cursor_position.x.round() as i32;
let cursor_y = cursor_position.y.round() as i32;
// Find the monitor that contains the cursor
available_monitors.into_iter().find(|monitor| {
let monitor_position = monitor.position();
let monitor_size = monitor.size();
cursor_x >= monitor_position.x
&& cursor_x <= monitor_position.x + monitor_size.width as i32
&& cursor_y >= monitor_position.y
&& cursor_y <= monitor_position.y + monitor_size.height as i32
})
} else {
None
};
// Use the target monitor or default to the primary monitor
let monitor = match target_monitor.or_else(|| window.primary_monitor().ok().flatten()) {
Some(monitor) => monitor,
None => {
log::error!("No monitor found!");
return;
}
};
let LogicalPosition { x, y } = point.to_logical(scale_factor);
match window.monitor_from_point(x, y) {
Ok(Some(monitor)) => {
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;
}
}
@@ -360,7 +368,7 @@ fn move_window_to_active_monitor(window: &WebviewWindow) {
let monitor_position = monitor.position();
let monitor_size = monitor.size();
// Get the current size of the window
// Current window size for horizontal centering
let window_size = match window.inner_size() {
Ok(size) => size,
Err(e) => {
@@ -368,26 +376,31 @@ fn move_window_to_active_monitor(window: &WebviewWindow) {
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
// Horizontal center uses actual width, vertical center uses 590 baseline
let window_x = monitor_position.x + (monitor_size.width as i32 - window_width) / 2;
let window_y = monitor_position.y + (monitor_size.height as i32 - window_height) / 2;
let window_y = monitor_position.y
+ (monitor_size.height as i32 - WINDOW_CENTER_BASELINE_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());
}
}
Ok(None) => {
log::error!("No monitor found at the specified point");
}
Err(e) => {
log::error!("Failed to get monitor from point: {}", e);
}
}
}
#[tauri::command]
async fn get_app_search_source(app_handle: AppHandle) -> Result<(), String> {
@@ -430,135 +443,3 @@ async fn hide_check(app_handle: AppHandle) {
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()
}

View File

@@ -1,42 +1,9 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use coco_lib::util::logging::app_log_dir;
use std::fs::OpenOptions;
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
fn setup_panic_hook() {

View File

@@ -4,19 +4,18 @@ use crate::common::search::{
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
};
use crate::common::traits::SearchSource;
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
use crate::server::servers::logout_coco_server;
use crate::server::servers::mark_server_as_offline;
use crate::settings::get_local_query_source_weight;
use function_name::named;
use futures::StreamExt;
use futures::stream::FuturesUnordered;
use reqwest::StatusCode;
use std::cmp::Reverse;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use tauri::{AppHandle, Manager};
use tokio::time::{Duration, timeout};
#[named]
#[tauri::command]
pub async fn query_coco_fusion(
@@ -187,7 +186,6 @@ async fn query_coco_fusion_multi_query_sources(
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 {
let query_source = query_source_trait_object.get_type().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 need_rerank = true; //TODO set default to false when boost supported in Pizza
let mut failed_requests = Vec::new();
let mut all_hits: Vec<(String, QueryHits, f64)> = Vec::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
}
let mut all_hits_grouped_by_query_source: HashMap<QuerySource, Vec<QueryHits>> = HashMap::new();
while let Some((query_source, timeout_result)) = futures.next().await {
match timeout_result {
@@ -229,7 +221,6 @@ async fn query_coco_fusion_multi_query_sources(
Ok(query_result) => match query_result {
Ok(response) => {
total_hits += response.total_hits;
let source_id = response.source.id.clone();
for (document, score) in response.hits {
log::debug!(
@@ -246,12 +237,10 @@ async fn query_coco_fusion_multi_query_sources(
document,
};
all_hits.push((source_id.clone(), query_hit.clone(), score));
hits_per_source
.entry(source_id.clone())
all_hits_grouped_by_query_source
.entry(query_source.clone())
.or_insert_with(Vec::new)
.push((query_hit, score));
.push(query_hit);
}
}
Err(search_error) => {
@@ -267,109 +256,129 @@ async fn query_coco_fusion_multi_query_sources(
}
}
// Sort hits within each source by score (descending)
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));
let n_sources = all_hits_grouped_by_query_source.len();
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 {
size as usize / total_sources
/*
* Apply settings: local query source weight
*/
let local_query_source_weight: f64 = get_local_query_source_weight(tauri_app_handle);
// 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 {
size as usize
hits.clone()
};
let mut final_hits = Vec::new();
let mut seen_docs = HashSet::new(); // To track documents we've already added
// 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);
}
}
final_hits_grouped_by_source_id.insert(query_source.id.clone(), hits_taken);
}
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
let final_hits_len = final_hits_grouped_by_source_id
.iter()
.enumerate()
.filter_map(|(idx, hit)| {
let source = hit.source.as_ref()?;
let title = hit.document.title.as_deref()?;
.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 source.id != crate::extension::built_in::calculator::DATA_SOURCE_ID {
Some((idx, title))
/*
* 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 {
None
*sorted_hits = &sorted_hits[1..];
}
})
.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);
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();
for (_source_id, hits) in final_hits_grouped_by_source_id {
final_hits.extend(hits);
}
// **Sort final hits by score descending**
@@ -379,6 +388,9 @@ async fn query_coco_fusion_multi_query_sources(
.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 {
//TODO: Add a recommendation system to suggest more sources
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;
fn boosted_levenshtein_rerank(
query: &str,
all_hits_grouped_by_source_id: &mut HashMap<String, Vec<QueryHits>>,
) {
let query_lower = query.to_lowercase();
titles
.into_iter()
.map(|(idx, title)| {
for (source_id, hits) in all_hits_grouped_by_source_id.iter_mut() {
// Skip special sources like calculator
if source_id == crate::extension::built_in::calculator::DATA_SOURCE_ID {
continue;
}
for hit in hits.iter_mut() {
let document_title = hit.document.title.as_deref().unwrap_or("");
let document_title_lowercase = document_title.to_lowercase();
let new_score = {
let mut score = 0.0;
if title.contains(query) {
// --- Exact or substring boost ---
if document_title.contains(query) {
score += 0.4;
} else if title.to_lowercase().contains(&query_lower) {
} else if document_title_lowercase.contains(&query_lower) {
score += 0.2;
}
let dist = levenshtein(&query_lower, &title.to_lowercase());
let max_len = query_lower.len().max(title.len());
if max_len > 0 {
score += (1.0 - (dist as f64 / max_len as f64)) as f32;
// --- 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;
}
}
}
(idx, score.min(1.0) as f64)
})
/// 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()
}

View File

@@ -4,6 +4,7 @@ use tauri::AppHandle;
use tauri_plugin_store::StoreExt;
const SETTINGS_ALLOW_SELF_SIGNATURE: &str = "settings_allow_self_signature";
const LOCAL_QUERY_SOURCE_WEIGHT: &str = "local_query_source_weight";
#[tauri::command]
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 {
_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),
}
}

View File

@@ -1,14 +1,26 @@
//! credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
use crate::common::MAIN_WINDOW_LABEL;
use objc2_app_kit::NSNonactivatingPanelMask;
use tauri::{AppHandle, Emitter, EventTarget, WebviewWindow};
use tauri_nspanel::{WebviewWindowExt, cocoa::appkit::NSWindowCollectionBehavior, panel_delegate};
use tauri::{AppHandle, Emitter, EventTarget, Manager, WebviewWindow};
use tauri_nspanel::{CollectionBehavior, PanelLevel, StyleMask, WebviewWindowExt, tauri_panel};
const WINDOW_FOCUS_EVENT: &str = "tauri://focus";
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(
_tauri_app_handle: &AppHandle,
@@ -17,68 +29,39 @@ pub fn platform(
_check_window: WebviewWindow,
) {
// 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
//
// 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);
panel.set_style_mask(StyleMask::empty().nonactivating_panel().into());
// Open the window in the active workspace and full screen
panel.set_collection_behaviour(
NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorStationary
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary,
panel.set_collection_behavior(
CollectionBehavior::new()
.stationary()
.move_to_active_space()
.full_screen_auxiliary()
.into(),
);
// Define the panel's delegate to listen to panel window events
let delegate = panel_delegate!(EcoPanelDelegate {
window_did_become_key,
window_did_resign_key,
window_did_resize,
window_did_move
});
let handler = NsPanelEventHandler::new();
// Set event listeners for the delegate
delegate.set_listener(Box::new(move |delegate_name: String| {
let window = main_window.clone();
handler.window_did_become_key(move |_| {
let target = EventTarget::labeled(MAIN_WINDOW_LABEL);
let window_move_event = || {
if let Ok(position) = main_window.outer_position() {
let _ = main_window.emit_to(target.clone(), WINDOW_MOVED_EVENT, position);
}
};
let _ = window.emit_to(target, WINDOW_FOCUS_EVENT, true);
});
match delegate_name.as_str() {
// Called when the window gets keyboard focus
"window_did_become_key" => {
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();
let window = main_window.clone();
handler.window_did_resign_key(move |_| {
let target = EventTarget::labeled(MAIN_WINDOW_LABEL);
if let Ok(size) = main_window.inner_size() {
let _ = main_window.emit_to(target, WINDOW_RESIZED_EVENT, size);
}
}
// Called when the window position changes
"window_did_move" => window_move_event(),
_ => (),
}
}));
let _ = window.emit_to(target, WINDOW_BLUR_EVENT, true);
});
// Set the delegate object for the window to handle window events
panel.set_delegate(delegate);
panel.set_event_handler(Some(handler.as_ref()));
}

View 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()
}

View File

@@ -1,10 +1,12 @@
pub(crate) mod app_lang;
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 platform;
pub(crate) mod prevent_default;
pub(crate) mod system_lang;
pub(crate) mod updater;
pub(crate) mod version;
use std::{path::Path, process::Command};
use tauri::AppHandle;

View File

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

View 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(&not_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
);
}
}

View File

@@ -65,20 +65,25 @@
"height": 260,
"minHeight": 260,
"center": false,
"decorations": false,
"transparent": true,
"maximizable": false,
"skipTaskbar": false,
"dragDropEnabled": false,
"hiddenTitle": true,
"visible": false,
"shadow": false,
"windowEffects": {
"effects": ["sidebar"],
"state": "active"
"state": "active",
"radius": 7
}
}
],
"security": {
"csp": null,
"csp": {
"default-src": "'self' asset: http: https: ipc: blob: data:"
},
"dangerousDisableAssetCspModification": true,
"assetProtocol": {
"enable": true,

View File

@@ -1,5 +1,7 @@
import axios from "axios";
axios.defaults.withCredentials = true;
import { useAppStore } from "@/stores/appStore";
import {
@@ -59,8 +61,19 @@ export const handleApiError = (error: any) => {
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);
if (!suppressProfileError) {
addError(message, "error");
}
return error;
};
@@ -78,7 +91,7 @@ export const Get = <T>(
}
axios
.get(baseURL + url, { params })
.get(baseURL + url, { params, withCredentials: true })
.then((result) => {
let res: FcResponse<T>;
if (clearFn !== undefined) {
@@ -113,6 +126,7 @@ export const Post = <T>(
.post(baseURL + url, data, {
params,
headers,
withCredentials: true,
} as any)
.then((result) => {
resolve([null, result.data as FcResponse<T>]);

View File

@@ -34,6 +34,7 @@ export async function streamPost({
...(headersStorage),
...(headers || {}),
},
credentials: "include",
body: JSON.stringify(body),
});

View File

@@ -30,19 +30,23 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
const setCurrentAssistant = useConnectStore((state) => {
return state.setCurrentAssistant;
});
const assistantList = useConnectStore((state) => state.assistantList);
const aiAssistant = useShortcutsStore((state) => state.aiAssistant);
const [assistants, setAssistants] = useState<any[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const popoverButtonRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const [keyword, setKeyword] = useState("");
const debounceKeyword = useDebounce(keyword, { wait: 500 });
const askAiAssistantId = useSearchStore((state) => state.askAiAssistantId);
const setAskAiAssistantId = useSearchStore((state) => {
return state.setAskAiAssistantId;
});
const assistantList = useConnectStore((state) => state.assistantList);
const { fetchAssistant } = AssistantFetcher({
debounceKeyword,
@@ -221,7 +225,7 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
autoFocus
value={keyword}
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) => {
setKeyword(event.target.value);
}}

View File

@@ -41,6 +41,7 @@ interface ChatAIProps {
startPage?: StartPage;
formatUrl?: (data: any) => string;
instanceId?: string;
getChatHistoryChatPage?: () => void;
}
export interface SendMessageParams {
@@ -52,6 +53,7 @@ export interface ChatAIRef {
init: (params: SendMessageParams) => void;
cancelChat: () => void;
clearChat: () => void;
onSelectChat: (chat: Chat) => void;
}
const ChatAI = memo(
@@ -73,6 +75,7 @@ const ChatAI = memo(
startPage,
formatUrl,
instanceId,
getChatHistoryChatPage,
},
ref
) => {
@@ -80,6 +83,7 @@ const ChatAI = memo(
init: init,
cancelChat: () => cancelChat(activeChat),
clearChat: clearChat,
onSelectChat: onSelectChat,
}));
const curChatEnd = useChatStore((state) => state.curChatEnd);
@@ -116,6 +120,12 @@ const ChatAI = memo(
activeChatProp && setActiveChat(activeChatProp);
}, [activeChatProp]);
useEffect(() => {
const { setHasActiveChat } = useChatStore.getState();
setHasActiveChat(Boolean(activeChat));
}, [activeChat]);
useEffect(() => {
if (!isTauri) return;
@@ -193,7 +203,8 @@ const ChatAI = memo(
isDeepThinkActive,
isMCPActive,
changeInput,
showChatHistory
showChatHistory,
getChatHistoryChatPage
);
const { dealMsg } = useMessageHandler(
@@ -377,7 +388,7 @@ const ChatAI = memo(
<div
data-tauri-drag-region
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
clearChat={clearChat}

View File

@@ -5,12 +5,14 @@ import { ChatMessage } from "@/components/ChatMessage";
import { Greetings } from "./Greetings";
import AttachmentList from "@/components/Assistant/AttachmentList";
import { useChatScroll } from "@/hooks/useChatScroll";
import type { Chat, IChunkData } from "@/types/chat";
import { useConnectStore } from "@/stores/connectStore";
// import SessionFile from "./SessionFile";
import ScrollToBottom from "@/components/Common/ScrollToBottom";
import { useChatStore } from "@/stores/chatStore";
import { useWebConfigStore } from "@/stores/webConfigStore";
import { useAppStore } from "@/stores/appStore";
import { NoResults } from "../Common/UI/NoResults";
interface ChatContentProps {
activeChat?: Chat;
@@ -45,20 +47,23 @@ export const ChatContent = ({
handleSendMessage,
formatUrl,
}: ChatContentProps) => {
const { currentSessionId, setCurrentSessionId } = useConnectStore();
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 { scrollToBottom } = useChatScroll(messagesEndRef);
const scrollRef = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const visibleStartPage = useConnectStore((state) => state.visibleStartPage);
const curChatEnd = useChatStore((state) => state.curChatEnd);
useEffect(() => {
setIsAtBottom(true);
@@ -95,8 +100,15 @@ export const ChatContent = ({
setIsAtBottom(isAtBottom);
};
const { isTauri } = useAppStore();
const { disabled } = useWebConfigStore();
return (
<div className="flex-1 overflow-hidden flex flex-col justify-between relative user-select-text">
{!isTauri && disabled ? (
<NoResults />
) : (
<>
<div
ref={scrollRef}
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"
@@ -130,7 +142,8 @@ export const ChatContent = ({
_source: {
type: "assistant",
assistant_id:
allMessages[allMessages.length - 1]?._source?.assistant_id,
allMessages[allMessages.length - 1]?._source
?.assistant_id,
message: "",
question: Question,
},
@@ -168,7 +181,10 @@ export const ChatContent = ({
</div>
{uploadAttachments.length > 0 && (
<div key={currentSessionId} className="max-h-[120px] overflow-auto p-2">
<div
key={currentSessionId}
className="max-h-[120px] overflow-auto p-2"
>
<AttachmentList />
</div>
)}
@@ -176,6 +192,8 @@ export const ChatContent = ({
{/* {currentSessionId && <SessionFile sessionId={currentSessionId} />} */}
<ScrollToBottom scrollRef={scrollRef} isAtBottom={isAtBottom} />
</>
)}
</div>
);
};

View File

@@ -1,9 +1,6 @@
import { MessageSquarePlus } from "lucide-react";
import clsx from "clsx";
import HistoryIcon from "@/icons/History";
import PinOffIcon from "@/icons/PinOff";
import PinIcon from "@/icons/Pin";
import WindowsFullIcon from "@/icons/WindowsFull";
import { useAppStore } from "@/stores/appStore";
import type { Chat } from "@/types/chat";
@@ -12,6 +9,8 @@ import { useShortcutsStore } from "@/stores/shortcutsStore";
import { HISTORY_PANEL_ID } from "@/constants";
import { AssistantList } from "./AssistantList";
import { ServerList } from "./ServerList";
import TogglePin from "../Common/TogglePin";
import WebLogin from "../WebLogin";
interface ChatHeaderProps {
clearChat: () => void;
@@ -34,21 +33,9 @@ export function ChatHeader({
showChatHistory = true,
assistantIDs,
}: ChatHeaderProps) {
const { isPinned, setIsPinned, isTauri } = useAppStore();
const { isTauri } = useAppStore();
const { historicalRecords, newSession, fixedWindow, external } =
useShortcutsStore();
const togglePin = async () => {
try {
const { isPinned } = useAppStore.getState();
setIsPinned(!isPinned);
} catch (err) {
console.error("Failed to toggle window pin state:", err);
setIsPinned(isPinned);
}
};
const { historicalRecords, newSession, external } = useShortcutsStore();
return (
<header
@@ -77,7 +64,7 @@ export function ChatHeader({
<AssistantList assistantIDs={assistantIDs} />
{showChatHistory ? (
{showChatHistory && (
<button
onClick={clearChat}
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" />
</VisibleKey>
</button>
) : null}
)}
</div>
<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 ? (
<div className="flex items-center gap-2">
<button
onClick={togglePin}
className={clsx("inline-flex", {
"text-blue-500": isPinned,
})}
>
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}>
{isPinned ? <PinIcon /> : <PinOffIcon />}
</VisibleKey>
</button>
<TogglePin className="inline-flex" />
<ServerList clearChat={clearChat} />
@@ -123,7 +101,7 @@ export function ChatHeader({
)}
</div>
) : (
<div />
<WebLogin panelClassName="top-8 right-0" />
)}
</header>
);

View File

@@ -28,7 +28,7 @@ const ConnectPrompt = () => {
<p className="mb-4 w-[388px]">{t("assistant.chat.connect_tip")}</p>
<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}
>
<span>{t("assistant.chat.connect")}</span>

View File

@@ -18,7 +18,10 @@ import StatusIndicator from "@/components/Cloud/StatusIndicator";
import { useAuthStore } from "@/stores/authStore";
import { useSearchStore } from "@/stores/searchStore";
import { useServers } from "@/hooks/useServers";
import { getCurrentWindowService, setCurrentWindowService } from "@/commands/windowService";
import {
getCurrentWindowService,
setCurrentWindowService,
} from "@/commands/windowService";
interface ServerListProps {
clearChat: () => void;
@@ -33,10 +36,9 @@ export function ServerList({ clearChat }: ServerListProps) {
);
const setEndpoint = useAppStore((state) => state.setEndpoint);
const isTauri = useAppStore((state) => state.isTauri);
const currentService = useConnectStore((state) => state.currentService);
const cloudSelectService = useConnectStore((state) => {
return state.cloudSelectService;
});
const serverList = useConnectStore((state) => state.serverList);
const { setMessages } = useChatStore();
@@ -55,7 +57,6 @@ export function ServerList({ clearChat }: ServerListProps) {
const serverListButtonRef = useRef<HTMLButtonElement>(null);
const { refreshServerList } = useServers();
const serverList = useConnectStore((state) => state.serverList);
const switchServer = async (server: IServer) => {
if (!server) return;
@@ -95,8 +96,10 @@ export function ServerList({ clearChat }: ServerListProps) {
} else {
switchServer(enabledServers[enabledServers.length - 1]);
}
} else {
setCurrentWindowService({});
}
}, [currentService?.id, cloudSelectService?.id, serverList]);
}, [serverList]);
useEffect(() => {
if (!askAiServerId || serverList.length === 0) return;
@@ -194,7 +197,7 @@ export function ServerList({ clearChat }: ServerListProps) {
<div className="flex items-center gap-2">
<button
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=",">
<Settings className="h-4 w-4 text-[#0287FF]" />
@@ -202,7 +205,7 @@ export function ServerList({ clearChat }: ServerListProps) {
</button>
<button
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}
>
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
@@ -229,11 +232,11 @@ export function ServerList({ clearChat }: ServerListProps) {
: "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
src={server?.provider?.icon || logoImg}
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) => {
const target = e.target as HTMLImageElement;
target.src = logoImg;

View File

@@ -37,8 +37,6 @@ const SessionFile = (props: SessionFileProps) => {
const getUploadedFiles = async () => {
if (isTauri) {
console.log("sessionId", sessionId);
const response: any = await platformAdapter.commands(
"get_attachment_by_ids",
{

View File

@@ -72,8 +72,6 @@ const Splash = ({ assistantIDs = [], startPage }: SplashProps) => {
setSettings(response);
};
console.log("currentService", currentService);
useEffect(() => {
getSettings();
fetchData();

View File

@@ -59,7 +59,7 @@ export function DataSourceItem({ name, icon, connector }: DataSourceItemProps) {
{icon?.startsWith("font_") ? (
<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">

View File

@@ -2,6 +2,7 @@ import { FC, memo, useCallback, useEffect, useState } from "react";
import { Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { v4 as uuidv4 } from "uuid";
import { useDebounceFn } from "ahooks";
import { UserProfile } from "./UserProfile";
import { OpenURLWithBrowser } from "@/utils";
@@ -20,7 +21,6 @@ const ServiceAuth = memo(
({ setRefreshLoading, refreshClick }: ServiceAuthProps) => {
const { t } = useTranslation();
const language = useAppStore((state) => state.language);
const addError = useAppStore((state) => state.addError);
const ssoRequestID = useAppStore((state) => state.ssoRequestID);
const setSSORequestID = useAppStore((state) => state.setSSORequestID);
@@ -61,19 +61,21 @@ const ServiceAuth = memo(
[logoutServer]
);
// handle oauth success event
useEffect(() => {
const unlistenOAuth = platformAdapter.listenEvent(
"oauth_success",
(event) => {
const { run: debouncedAuthSuccess } = useDebounceFn((event) => {
const { serverId } = event.payload;
if (serverId) {
refreshClick(serverId, () => {
setLoading(false);
});
addError(language === "zh" ? "登录成功" : "Login Success", "info");
}
addError(t("cloud.connect.hints.loginSuccess"), "info");
}
});
// handle oauth success event
useEffect(() => {
const unlistenOAuth = platformAdapter.listenEvent(
"oauth_success",
debouncedAuthSuccess
);
return () => {
@@ -163,7 +165,7 @@ const LoginButton: FC<LoginButtonProps> = memo((props) => {
return (
<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}
aria-label={t("cloud.login")}
>
@@ -182,9 +184,9 @@ const LoadingState: FC<LoadingStateProps> = memo((props) => {
const { t } = useTranslation();
return (
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 mb-3">
<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}
>
{t("cloud.cancel")}

View File

@@ -53,7 +53,7 @@ export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
<img
src={item?.provider?.icon || cocoLogoImg}
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) => {
const target = e.target as HTMLImageElement;
target.src = cocoLogoImg;

View File

@@ -24,13 +24,13 @@ export function UserProfile({ server, userInfo, onLogout }: UserProfileProps) {
<img
src={userInfo?.avatar}
alt=""
className="w-6 h-6"
className="w-6 h-6 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
onError={() => {
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 className="flex-1">

View File

@@ -62,13 +62,13 @@ const ApiDetails: React.FC = () => {
{logs.map((log, index) => (
<div
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">
Latest Request {index + 1}:
</h4>
<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)}
</pre>
</div>
@@ -87,7 +87,7 @@ const ApiDetails: React.FC = () => {
</h4>
{showIndex === index ? (
<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)}
</pre>
</div>
@@ -98,7 +98,7 @@ const ApiDetails: React.FC = () => {
<>
<h4 className="font-semibold text-red-800 mt-4">Error:</h4>
<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)}
</pre>
</div>

View File

@@ -29,7 +29,7 @@ const ChatSwitch: React.FC<ChatSwitchProps> = ({ isChatMode, onChange }) => {
<div
role="switch"
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)]"
}`}
onClick={handleToggle}
@@ -39,8 +39,8 @@ const ChatSwitch: React.FC<ChatSwitchProps> = ({ isChatMode, onChange }) => {
{!isChatMode ? <Search className="w-4 h-4 text-white" /> : <div></div>}
</div>
<div
className={`absolute top-[1px] h-4 w-4 bg-white rounded-full shadow-md transform transition-transform duration-300 ${
isChatMode ? "translate-x-6" : "translate-x-0"
className={`absolute top-[1px] left-[1px] h-[18px] w-[18px] bg-white rounded-full shadow-md transform transition-transform duration-300 ${
isChatMode ? "translate-x-5" : "translate-x-0"
}`}
></div>
</div>

View File

@@ -16,6 +16,7 @@ const ErrorNotification = ({
}: ErrorNotificationProps) => {
const errors = useAppStore((state) => state.errors);
const removeError = useAppStore((state) => state.removeError);
const suppressErrors = useAppStore((state) => state.suppressErrors);
useEffect(() => {
if (!autoClose) return;
@@ -32,7 +33,7 @@ const ErrorNotification = ({
return () => clearInterval(timer);
}, [errors, duration, autoClose]);
if (errors.length === 0) return null;
if (errors.length === 0 || suppressErrors) return null;
return (
<div

View File

@@ -169,7 +169,7 @@ const HistoryListItem: FC<HistoryListItemProps> = ({
return (
<button
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}
>
<VisibleKey shortcut={shortcut} onKeyPress={onClick}>

View File

@@ -8,7 +8,7 @@ interface FontIconProps {
const FontIcon: FC<FontIconProps> = ({ name, className, style, ...rest }) => {
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}`} />
</svg>
);

View File

@@ -24,7 +24,12 @@ function ThemedIcon({ component: Component, className = "" }: ThemedIconProps) {
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;

View File

@@ -49,7 +49,13 @@ function UniversalIcon({
// Render image type icon
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 ? (
<IconWrapper className={className} onClick={onClick}>
{img}
@@ -63,7 +69,7 @@ function UniversalIcon({
const renderAppIcon = (src: string) => {
const img = (
<img
className={className}
className={`dark:drop-shadow-[0_0_6px_rgb(255,255,255)] ${className}`}
src={platformAdapter.convertFileSrc(src)}
alt="icon"
/>

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

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

View File

@@ -27,7 +27,7 @@ const Tooltip2: FC<Tooltip2Props> = (props) => {
static
anchor={anchor}
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,
}

View File

@@ -4,14 +4,10 @@ import { useTranslation } from "react-i18next";
import clsx from "clsx";
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 { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore";
import { useUpdateStore } from "@/stores/updateStore";
import VisibleKey from "../VisibleKey";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import { formatKey } from "@/utils/keyboardUtils";
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 platformAdapter from "@/utils/platformAdapter";
import FontIcon from "../Icons/FontIcon";
import TogglePin from "../TogglePin";
import WebLogin from "@/components/WebLogin";
interface FooterProps {
setIsPinnedWeb?: (value: boolean) => void;
@@ -37,28 +35,11 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
const isDark = useThemeStore((state) => state.isDark);
const { isTauri, isPinned, setIsPinned } = useAppStore();
const { isTauri } = useAppStore();
const { setVisible, updateInfo, skipVersions } = useUpdateStore();
const { fixedWindow, 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 { modifierKey } = useShortcutsStore();
const openSetting = useCallback(() => {
return platformAdapter.emitEvent("open_settings", "");
@@ -68,7 +49,7 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
return updateInfo && !skipVersions.includes(updateInfo.version);
}, [updateInfo, skipVersions]);
const renderLeft = () => {
const renderTauriLeft = () => {
if (sourceData?.source?.name) {
return (
<div className="flex items-center gap-2">
@@ -88,7 +69,10 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
if (visibleExtensionDetail && selectedExtension) {
return (
<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>
</div>
);
@@ -132,28 +116,28 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
return (
<div
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 ? (
<div className="flex items-center">
<div className="flex items-center space-x-2">
{renderLeft()}
{renderTauriLeft()}
<button
onClick={togglePin}
<TogglePin
className={clsx({
"text-blue-500": isPinned,
"pl-2": hasUpdate,
})}
>
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}>
{isPinned ? <PinIcon /> : <PinOffIcon />}
</VisibleKey>
</button>
setIsPinnedWeb={setIsPinnedWeb}
/>
</div>
</div>
) : (
<Copyright />
<WebLogin panelClassName="bottom-5 left-0" />
)}
<div className={`flex mobile:hidden items-center gap-3`}>

View File

@@ -5,6 +5,11 @@ import { useShortcutsStore } from "@/stores/shortcutsStore";
import clsx from "clsx";
import { formatKey } from "@/utils/keyboardUtils";
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 = () => {
const { t } = useTranslation();
@@ -12,11 +17,34 @@ export const NoResults = () => {
const modifierKey = useShortcutsStore((state) => state.modifierKey);
const modeSwitch = useShortcutsStore((state) => state.modeSwitch);
const { isTauri } = useAppStore();
const { disabled } = useWebConfigStore();
const renderContent = () => {
if (!isTauri && disabled) {
return (
<div
data-tauri-drag-region
className="h-full w-full flex flex-col justify-center items-center"
>
<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
@@ -26,7 +54,7 @@ export const NoResults = () => {
<span
className={clsx(
"ml-3 h-5 min-w-5 rounded-md border border-[#D8D8D8] flex justify-center items-center",
"ml-3 h-5 min-w-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center",
{
"px-1": !isMac,
}
@@ -39,6 +67,16 @@ export const NoResults = () => {
{modeSwitch}
</span>
</div>
</>
);
};
return (
<div
data-tauri-drag-region
className="h-full w-full flex flex-col justify-center items-center"
>
{renderContent()}
</div>
);
};

View File

@@ -27,7 +27,7 @@ const Footer = () => {
active
? "bg-gray-100 dark:bg-gray-700"
: "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" />
<Link to={`/`}>Home</Link>
@@ -41,7 +41,7 @@ const Footer = () => {
active
? "bg-gray-100 dark:bg-gray-700"
: "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" />
Profile
@@ -55,7 +55,7 @@ const Footer = () => {
active
? "bg-gray-100 dark:bg-gray-700"
: "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" />
<Link to={`settings`}>Settings</Link>
@@ -70,7 +70,7 @@ const Footer = () => {
active
? "bg-gray-100 dark:bg-gray-700"
: "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" />
Sign Out

View File

@@ -6,6 +6,9 @@ import { last } from "lodash-es";
import { POPOVER_PANEL_SELECTOR } from "@/constants";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import { useAppStore } from "@/stores/appStore";
import { KeyType } from "ahooks/lib/useKeyPress";
const keyTriggerMap = new Map<KeyType, number>();
interface VisibleKeyProps extends HTMLAttributes<HTMLDivElement> {
shortcut: string;
@@ -60,8 +63,16 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
setVisibleShortcut(isChildInPopover && modifierKeyPressed);
}, [openPopover, modifierKeyPressed]);
useKeyPress(`${modifierKey}.${shortcut}`, (event) => {
if (!visibleShortcut) return;
useKeyPress(`${modifierKey}.${shortcut}`, (event, key) => {
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.preventDefault();
@@ -82,6 +93,10 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
return "↩︎";
}
if (shortcut === "backspace") {
return "⌫";
}
return shortcut;
};
@@ -96,7 +111,7 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
{showTooltip && visibleShortcut ? (
<div
className={clsx(
"size-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
"size-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[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
)}
>

View File

@@ -40,7 +40,7 @@ const AiOverview: FC<AiSummaryProps> = (props) => {
)}
>
<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={() => {
setVisible(false);
}}

View File

@@ -107,7 +107,7 @@ const AskAi: FC<AskAiProps> = (props) => {
unlisten.current = await platformAdapter.listenEvent(
"quick-ai-access-client-id",
({ payload }) => {
console.log("ask_ai", JSON.parse(payload));
// console.log("ask_ai", JSON.parse(payload));
const chunkData = JSON.parse(payload);

View File

@@ -7,6 +7,9 @@ import platformAdapter from "@/utils/platformAdapter";
import { Get } from "@/api/axiosRequest";
import type { Assistant } from "@/types/chat";
import { useAppStore } from "@/stores/appStore";
import { canNavigateBack, navigateBack } from "@/utils";
import { useKeyPress } from "ahooks";
import { useShortcutsStore } from "@/stores/shortcutsStore";
interface AssistantManagerProps {
isChatMode: boolean;
@@ -33,9 +36,9 @@ export function useAssistantManager({
setVisibleExtensionStore,
setSearchValue,
visibleExtensionDetail,
setVisibleExtensionDetail,
sourceData,
setSourceData,
setVisibleExtensionDetail,
} = useSearchStore();
const { quickAiAccessAssistant, disabledExtensions } = useExtensionsStore();
@@ -47,6 +50,7 @@ export function useAssistantManager({
}, [quickAiAccessAssistant, selectedAssistant]);
const [assistantDetail, setAssistantDetail] = useState<any>({});
const { modifierKey } = useShortcutsStore();
useEffect(() => {
if (goAskAi) return;
@@ -99,39 +103,14 @@ export function useAssistantManager({
const handleKeyDownAutoResizeTextarea = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const { key, shiftKey, currentTarget } = e;
const { value } = currentTarget;
const { value, selectionStart, selectionEnd } = currentTarget;
if (key === "Backspace" && value === "") {
if (goAskAi) {
return setGoAskAi(false);
}
const cursorStart = selectionStart === 0 && selectionEnd === 0;
if (visibleExtensionDetail) {
return setVisibleExtensionDetail(false);
}
if (visibleExtensionStore) {
return setVisibleExtensionStore(false);
}
if (sourceData) {
return setSourceData(void 0);
}
}
if (key === "Tab" && !isChatMode && isTauri) {
if (key === "Backspace" && (value === "" || cursorStart)) {
e.preventDefault();
if (visibleExtensionStore) return;
if (selectedSearchContent?.id === "Extension Store") {
changeInput("");
setSearchValue("");
return setVisibleExtensionStore(true);
}
assistant_get();
return handleAskAi();
return navigateBack();
}
if (key === "Enter" && !shiftKey) {
@@ -147,6 +126,17 @@ export function useAssistantManager({
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,
@@ -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 {
askAI,
askAIRef,

View File

@@ -1,3 +1,5 @@
import { useAppStore } from "@/stores/appStore";
import { useWebConfigStore } from "@/stores/webConfigStore";
import { useBoolean } from "ahooks";
import {
useImperativeHandle,
@@ -101,6 +103,9 @@ const AutoResizeTextarea = forwardRef<
[setInput]
);
const { isTauri } = useAppStore();
const { disabled } = useWebConfigStore();
return (
<>
<textarea
@@ -121,6 +126,7 @@ const AutoResizeTextarea = forwardRef<
setTimeout(setFalse, 0);
}}
rows={1}
disabled={!isTauri && disabled}
/>
<div ref={calcRef} className="absolute whitespace-nowrap -z-10">

View File

@@ -334,7 +334,7 @@ const ContextMenu = ({ formatUrl }: ContextMenuProps) => {
<kbd
key={key}
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,
}

View File

@@ -1,6 +1,9 @@
import React, { useState, useRef, useEffect, useCallback } from "react";
import { useInfiniteScroll } from "ahooks";
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 { SearchHeader } from "./SearchHeader";
@@ -11,9 +14,7 @@ import { Get } from "@/api/axiosRequest";
import { useAppStore } from "@/stores/appStore";
import { useConnectStore } from "@/stores/connectStore";
import SearchEmpty from "../Common/SearchEmpty";
import { Data } from "ahooks/lib/useInfiniteScroll/types";
import { nanoid } from "nanoid";
import { isNil } from "lodash-es";
import Scrollbar from "@/components/Common/Scrollbar";
interface DocumentListProps {
onSelectDocument: (id: string) => void;
@@ -129,7 +130,6 @@ export const DocumentList: React.FC<DocumentListProps> = ({
// set first select hover
if (from === 0 && list.length > 0) {
setSelectedItem(0);
getDocDetail(list[0]?.document);
}
if (taskId === taskIdRef.current) {
@@ -192,12 +192,11 @@ export const DocumentList: React.FC<DocumentListProps> = ({
);
const onMouseEnter = useCallback(
(index: number, item: any) => {
(index: number) => {
if (isKeyboardMode) return;
getDocDetail(item);
setSelectedItem(index);
},
[isKeyboardMode, getDocDetail]
[isKeyboardMode]
);
useEffect(() => {
@@ -235,7 +234,6 @@ export const DocumentList: React.FC<DocumentListProps> = ({
? Math.max(0, prev - 1)
: Math.min(data.list.length - 1, prev + 1);
getDocDetail(data.list[nextIndex]?.document);
itemRefs.current[nextIndex]?.scrollIntoView({
behavior: "smooth",
block: "nearest",
@@ -283,6 +281,14 @@ export const DocumentList: React.FC<DocumentListProps> = ({
};
}, [handleKeyDown, handleMouseMove]);
useEffect(() => {
if (selectedItem === null) return;
const doc = data.list[selectedItem]?.document;
if (doc) {
getDocDetail(doc);
}
}, [selectedItem, data, getDocDetail]);
return (
<div
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
className="flex-1 overflow-auto custom-scrollbar pr-0.5"
ref={containerRef}
>
<Scrollbar className="flex-1 overflow-auto pr-0.5" ref={containerRef}>
{data?.list && data.list.length > 0 && (
<div>
{data.list.map((hit, index) => (
@@ -310,7 +313,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
item={{ ...hit.document, querySource: hit.source }}
isSelected={selectedItem === index}
currentIndex={index}
onMouseEnter={() => onMouseEnter(index, hit.document)}
onMouseEnter={() => onMouseEnter(index)}
onItemClick={() => {
platformAdapter.openSearchItem(hit.document, formatUrl);
}}
@@ -334,7 +337,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
<SearchEmpty />
</div>
)}
</div>
</Scrollbar>
</div>
);
};

View File

@@ -16,6 +16,7 @@ import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
import { SearchSource } from "./SearchSource";
import DropdownListItem from "./DropdownListItem";
import platformAdapter from "@/utils/platformAdapter";
import Scrollbar from "@/components/Common/Scrollbar";
type ISearchData = Record<string, QueryHits[]>;
@@ -145,13 +146,14 @@ function DropdownList({
handleItemAction,
isChatMode,
formatUrl,
searchData,
});
return (
<div
<Scrollbar
ref={containerRef}
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}
role="listbox"
aria-label={t("search.header.results")}
@@ -170,12 +172,13 @@ function DropdownList({
/>
)}
{items.map((hit) => {
const currentIndex = hit.document.index || 0;
{items.map((hit, idx) => {
const currentIndex = hit.document.index ?? 0;
const itemKey = `${sourceName}-${hit.document.id ?? currentIndex}-${idx}`;
return (
<DropdownListItem
key={hit.document.id}
key={itemKey}
item={hit.document}
selectedIndex={selectedIndex}
currentIndex={currentIndex}
@@ -188,7 +191,7 @@ function DropdownList({
})}
</div>
))}
</div>
</Scrollbar>
);
}

View File

@@ -5,7 +5,7 @@ import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { useSearchStore } from "@/stores/searchStore";
import { parseSearchQuery } from "@/utils";
import { installExtensionError, parseSearchQuery } from "@/utils";
import platformAdapter from "@/utils/platformAdapter";
import SearchEmpty from "../Common/SearchEmpty";
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 ?? []);
@@ -244,7 +244,7 @@ const ExtensionStore = ({ extensionId }: { extensionId?: string }) => {
"info"
);
} catch (error) {
addError(String(error), "error");
installExtensionError(String(error));
} finally {
const { installingExtensions } = useSearchStore.getState();

View File

@@ -18,10 +18,17 @@ import { useAssistantManager } from "./AssistantManager";
import InputControls from "./InputControls";
import { useExtensionsStore } from "@/stores/extensionsStore";
import AudioRecording from "../AudioRecording";
import { getUploadedAttachmentsId, isDefaultServer } from "@/utils";
import {
canNavigateBack,
getUploadedAttachmentsId,
isDefaultServer,
visibleFilterBar,
visibleSearchBar,
} from "@/utils";
import { useTauriFocus } from "@/hooks/useTauriFocus";
import { SendMessageParams } from "../Assistant/Chat";
import { isEmpty } from "lodash-es";
import { formatKey } from "@/utils/keyboardUtils";
interface ChatInputProps {
onSend: (params: SendMessageParams) => void;
@@ -88,7 +95,7 @@ export default function ChatInput({
const { currentAssistant } = useConnectStore();
const { sourceData, goAskAi } = useSearchStore();
const { goAskAi } = useSearchStore();
const { modifierKey, returnToInput, setModifierKeyPressed } =
useShortcutsStore();
@@ -103,8 +110,7 @@ export default function ChatInput({
const textareaRef = useRef<{ reset: () => void; focus: () => void }>(null);
const { curChatEnd } = useChatStore();
const { setSearchValue, visibleExtensionStore, selectedExtension } =
useSearchStore();
const { setSearchValue } = useSearchStore();
const { uploadAttachments } = useChatStore();
useTauriFocus({
@@ -121,8 +127,6 @@ export default function ChatInput({
const handleSubmit = useCallback(() => {
const trimmedValue = inputValue.trim();
console.log("handleSubmit", trimmedValue, disabled);
if ((trimmedValue || !isEmpty(uploadAttachments)) && !disabled) {
changeInput("");
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 &&
isTauri &&
!goAskAi &&
askAI &&
!disabledExtensions.includes("QuickAIAccess") &&
!visibleExtensionStore && (
!canNavigateBack() && (
<div className="flex items-center gap-2 text-sm text-[#AEAEAE] dark:text-[#545454] whitespace-nowrap">
<span>
{t("search.askCocoAi.title", {
replace: [akiAiTooltipPrefix, askAI.name],
})}
</span>
<div className="flex items-center justify-center w-8 h-[20px] text-xs rounded-md border border-black/10 dark:border-[#545454]">
Tab
<div className="flex items-center justify-center px-1 h-[20px] text-xs rounded-[6px] border border-black/10 dark:border-[#545454]">
{formatKey(modifierKey)} + {formatKey("Enter")}
</div>
</div>
)}
@@ -329,17 +306,21 @@ export default function ChatInput({
return (
<div className={`w-full relative`}>
<div
className={`p-2 flex items-center dark:text-[#D8D8D8] bg-[#ededed] dark:bg-[#202126] rounded-md transition-all relative overflow-hidden`}
>
<div
ref={containerRef}
className={clsx("relative w-full", {
"flex items-center gap-2": lineCount === 1,
})}
className={`flex items-center dark:text-[#D8D8D8] rounded-[6px] transition-all relative overflow-hidden`}
>
{lineCount === 1 && renderSearchIcon()}
{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()}
@@ -352,8 +333,10 @@ export default function ChatInput({
</div>
)}
</div>
)}
</div>
{visibleFilterBar() && (
<InputControls
isChatMode={isChatMode}
isChatPage={isChatPage}
@@ -377,6 +360,7 @@ export default function ChatInput({
getFileMetadata={getFileMetadata}
getFileIcon={getFileIcon}
/>
)}
</div>
);
}

View File

@@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
import SearchPopover from "./SearchPopover";
import MCPPopover from "./MCPPopover";
import ChatSwitch from "@/components/Common/ChatSwitch";
import Copyright from "@/components/Common/Copyright";
import type { DataSource } from "@/types/commands";
import platformAdapter from "@/utils/platformAdapter";
import { useConnectStore } from "@/stores/connectStore";
@@ -17,6 +16,7 @@ import { useSearchStore } from "@/stores/searchStore";
import { useExtensionsStore } from "@/stores/extensionsStore";
import { parseSearchQuery, SearchQuery } from "@/utils";
import InputUpload from "./InputUpload";
import Copyright from "../Common/Copyright";
interface InputControlsProps {
isChatMode: boolean;
@@ -187,7 +187,7 @@ const InputControls = ({
{source?.type === "deep_think" && source?.config?.visible && (
<button
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,
}
@@ -250,7 +250,7 @@ const InputControls = ({
!visibleExtensionStore && (
<div
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
? "text-[#881c94]"
@@ -270,7 +270,7 @@ const InputControls = ({
setEnabledAiOverview(!enabledAiOverview);
}}
>
<Sparkles className="size-4" />
<Sparkles className="size-3" />
</VisibleKey>
<span

View File

@@ -199,7 +199,7 @@ const InputUpload: FC<InputUploadProps> = (props) => {
return (
<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
content={t("search.input.uploadFileHints.tooltip", {
replace: [

View File

@@ -166,7 +166,7 @@ export default function MCPPopover({
return (
<div
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,
}

View File

@@ -10,7 +10,9 @@ import AskAi from "./AskAi";
import { useSearch } from "@/hooks/useSearch";
import ExtensionStore from "./ExtensionStore";
import platformAdapter from "@/utils/platformAdapter";
import ViewExtension from "./ViewExtension"
import ViewExtension from "./ViewExtension";
import { visibleFooterBar } from "@/utils";
import clsx from "clsx";
const SearchResultsPanel = memo<{
input: string;
@@ -47,8 +49,12 @@ const SearchResultsPanel = memo<{
}
}, [input, isChatMode, performSearch, sourceData]);
const { setSelectedAssistant, selectedSearchContent, visibleExtensionStore, viewExtensionOpened } =
useSearchStore();
const {
setSelectedAssistant,
selectedSearchContent,
visibleExtensionStore,
viewExtensionOpened,
} = useSearchStore();
useEffect(() => {
if (selectedSearchContent?.type === "AI Assistant") {
@@ -164,7 +170,12 @@ function Search({
const mainWindowRef = useRef<HTMLDivElement>(null);
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
input={input}
isChatMode={isChatMode}
@@ -173,7 +184,7 @@ function Search({
formatUrl={formatUrl}
/>
<Footer setIsPinnedWeb={setIsPinned} />
{visibleFooterBar() && <Footer setIsPinnedWeb={setIsPinned} />}
<ContextMenu formatUrl={formatUrl} />
</div>

View File

@@ -1,7 +1,61 @@
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 { 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 {
lineCount: number;
@@ -16,13 +70,11 @@ export default function SearchIcons({
}: SearchIconsProps) {
const {
sourceData,
setSourceData,
goAskAi,
setGoAskAi,
visibleExtensionStore,
setVisibleExtensionStore,
visibleExtensionDetail,
setVisibleExtensionDetail,
selectedExtension,
viewExtensionOpened,
} = useSearchStore();
if (isChatMode) {
@@ -30,54 +82,43 @@ export default function SearchIcons({
}
const renderContent = () => {
if (goAskAi && assistant) {
return (
<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
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) {
return (
<ArrowBigLeft
className="w-4 h-4 text-[#ccc] dark:text-[#d8d8d8] cursor-pointer"
onClick={() => {
if (visibleExtensionDetail) {
return setVisibleExtensionDetail(false);
}
if (visibleExtensionStore) {
return setVisibleExtensionStore(false);
if (visibleExtensionDetail && selectedExtension) {
const { name, icon } = selectedExtension;
return <MultilevelWrapper title={name} icon={icon} />;
}
setSourceData(void 0);
}}
/>
return <MultilevelWrapper title="Extensions Store" icon="font_Store" />;
}
if (goAskAi && assistant) {
const { name, icon } = assistant;
return <MultilevelWrapper title={name} icon={icon} />;
}
if (sourceData) {
const { source } = sourceData;
const { name, icon } = source;
return <MultilevelWrapper title={name} icon={icon} />;
}
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>
);
}
return <Search className="w-4 h-4 text-[#ccc] dark:text-[#d8d8d8]" />;
};
if (lineCount === 1) {

View File

@@ -172,7 +172,7 @@ export default function SearchPopover({
return (
<div
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,
}

View File

@@ -41,7 +41,8 @@ export const SearchSource: React.FC<SearchSourceProps> = ({
defaultIcon={isDark ? source_default_dark_img : source_default_img}
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>
{!hideArrow && (
<>
@@ -56,7 +57,7 @@ export const SearchSource: React.FC<SearchSourceProps> = ({
</IconWrapper>
{showIndex && sourceName === selectedName && (
<div className="absolute top-1 right-4">
<VisibleKey shortcut="" />
<VisibleKey shortcut="Tab" shortcutClassName="w-8" />
</div>
)}
</>

View File

@@ -1,22 +1,19 @@
/*
* ViewExtension.tsx
*
* View that will be rendered when opening a View extension.
*
*/
import React from "react";
import { useState, useEffect, useMemo } from "react";
import { ArrowLeft } from "lucide-react";
import { useSearchStore } from "@/stores/searchStore";
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
import { ExtensionFileSystemPermission, FileSystemAccess } from "../Settings/Extensions";
import {
ExtensionFileSystemPermission,
FileSystemAccess,
} from "../Settings/Extensions";
import platformAdapter from "@/utils/platformAdapter";
import { useShortcutsStore } from "@/stores/shortcutsStore";
const ViewExtension: React.FC = () => {
const { setViewExtensionOpened, viewExtensionOpened } = useSearchStore();
const [pagePath, setPagePath] = useState<string>("");
const { viewExtensionOpened } = useSearchStore();
// Complete list of the backend APIs, grouped by their category.
const [apis, setApis] = useState<Map<string, string[]> | null>(null);
const { setModifierKeyPressed } = useShortcutsStore();
if (viewExtensionOpened == 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
useEffect(() => {
setModifierKeyPressed(false);
const fetchApis = async () => {
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)));
} catch (error) {
console.error("Failed to fetch APIs:", error);
@@ -53,12 +40,8 @@ const ViewExtension: React.FC = () => {
fetchApis();
}, []);
const handleBack = () => {
setViewExtensionOpened(null);
};
// 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
// easier, reverse the map key values: {"api1": "category", "api2": "category"}
@@ -108,10 +91,10 @@ const ViewExtension: React.FC = () => {
const category = reversedApis.get(command)!;
var api = null;
if (permission == null) {
api = null
api = null;
} else {
api = permission.api
};
api = permission.api;
}
if (!apiPermissionCheck(category, command, api)) {
source.postMessage(
{
@@ -126,10 +109,10 @@ const ViewExtension: React.FC = () => {
var fs = null;
if (permission == null) {
fs = null
fs = null;
} else {
fs = permission.fs
};
fs = permission.fs;
}
if (!(await fsPermissionCheck(command, event.data, fs))) {
source.postMessage(
{
@@ -145,7 +128,12 @@ const ViewExtension: React.FC = () => {
if (command === "read_dir") {
const { path } = event.data;
try {
const fileNames: [String] = await invoke("read_dir", { path: path });
const fileNames: [String] = await platformAdapter.invokeBackend(
"read_dir",
{
path: path,
}
);
source.postMessage(
{
id,
@@ -171,49 +159,43 @@ const ViewExtension: React.FC = () => {
console.info("Coco extension API listener is up");
return () => {
window.removeEventListener('message', messageHandler);
window.removeEventListener("message", messageHandler);
};
}, [reversedApis, permission]); // Add apiPermissions as dependency
return (
<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>
const fileUrl = viewExtensionOpened[2];
{/* Main content */}
<div className="flex-1">
return (
<iframe
src={pagePath}
src={fileUrl}
className="w-full h-full border-0"
>
</iframe>
</div>
</div>
onLoad={(event) => {
event.currentTarget.focus();
}}
/>
);
};
export default ViewExtension;
// 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) {
return false;
}
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) {
case "read_dir": {
const { path } = requestPayload;
@@ -224,21 +206,27 @@ const extractFsAccessPattern = (command: string, requestPayload: any): [string,
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) {
return false;
}
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
for (const permission of fsPermission) {
if (permission.path === clean_path) {
// 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)
);
@@ -249,4 +237,4 @@ const fsPermissionCheck = async (command: string, requestPayload: any, fsPermiss
}
return false;
}
};

View File

@@ -9,7 +9,7 @@ import {
useMemo,
} from "react";
import clsx from "clsx";
import { useMount } from "ahooks";
import { useMount, useMutationObserver } from "ahooks";
import Search from "@/components/Search/Search";
import InputBox from "@/components/Search/InputBox";
@@ -21,14 +21,22 @@ import { isLinux, isWin } from "@/utils/platform";
import { appReducer, initialAppState } from "@/reducers/appReducer";
import { useWindowEvents } from "@/hooks/useWindowEvents";
import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore";
import platformAdapter from "@/utils/platformAdapter";
import { useStartupStore } from "@/stores/startupStore";
import { useThemeStore } from "@/stores/themeStore";
import { useConnectStore } from "@/stores/connectStore";
import { useAppearanceStore } from "@/stores/appearanceStore";
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 {
isTauri?: boolean;
@@ -79,6 +87,7 @@ function SearchChat({
);
const [state, dispatch] = useReducer(appReducer, customInitialState);
const {
isChatMode,
input,
@@ -88,6 +97,78 @@ function SearchChat({
isMCPActive,
isTyping,
} = 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(() => {
dispatch({
type: "SET_SEARCH_ACTIVE",
@@ -105,18 +186,12 @@ function SearchChat({
const [isWin10, setIsWin10] = useState(false);
const blurred = useAppStore((state) => state.blurred);
const { viewExtensionOpened } = useSearchStore();
useWindowEvents();
const setTheme = useThemeStore((state) => state.setTheme);
const setIsDark = useThemeStore((state) => state.setIsDark);
const isChatModeRef = useRef(false);
useEffect(() => {
isChatModeRef.current = isChatMode;
}, [isChatMode]);
useMount(async () => {
const isWin10 = await platformAdapter.isWindows10();
@@ -153,6 +228,8 @@ function SearchChat({
dispatch({ type: "SET_INPUT", payload: params?.message ?? "" });
if (isChatMode) {
const { setHasActiveChat } = useChatStore.getState();
setHasActiveChat(true);
chatAIRef.current?.init(params);
}
},
@@ -231,7 +308,7 @@ function SearchChat({
return state.defaultStartupWindow;
});
const opacity = useAppearanceStore((state) => state.opacity);
const { normalOpacity, blurOpacity } = useAppearanceStore();
useEffect(() => {
if (isTauri) {
@@ -249,11 +326,11 @@ function SearchChat({
<div
data-tauri-drag-region={isTauri}
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
? "bg-chat_bg_light dark:bg-chat_bg_dark"
: "bg-search_bg_light dark:bg-search_bg_dark",
? "bg-bottom bg-chat_bg_light dark:bg-chat_bg_dark"
: "bg-top bg-search_bg_light dark:bg-search_bg_dark",
],
{
"size-full": !isTauri,
@@ -263,7 +340,10 @@ function SearchChat({
"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
data-tauri-drag-region={isTauri}
@@ -289,15 +369,24 @@ function SearchChat({
</Suspense>
</div>
{/* We don't want this inputbox when rendering View extensions */}
{/* TODO: figure out a better way to disable this inputbox */}
{!viewExtensionOpened && (
<div
data-tauri-drag-region={isTauri}
className={`p-2 w-full flex justify-center transition-all duration-500 min-h-[82px] ${
isTransitioned ? "border-t" : "border-b"
} border-[#E6E6E6] dark:border-[#272626]`}
className={clsx(
"relative p-2 w-full flex justify-center transition-all duration-500",
{
"min-h-[84px]": visibleSearchBar() && visibleFilterBar(),
}
)}
>
{!hideMiddleBorder && (
<div
className={clsx(
"pointer-events-none absolute left-0 right-0 h-[1px] bg-[#E6E6E6] dark:bg-[#272626]",
isTransitioned ? "top-0" : "bottom-0"
)}
/>
)}
<InputBox
isChatMode={isChatMode}
inputValue={input}
@@ -327,7 +416,6 @@ function SearchChat({
chatPlaceholder={chatPlaceholder}
/>
</div>
)}
<div
data-tauri-drag-region={isTauri}

View File

@@ -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 { OpenURLWithBrowser } from "@/utils";
import logoLight from "@/assets/images/logo-text-light.svg";
import logoDark from "@/assets/images/logo-text-dark.svg";
import lightLogo from "@/assets/images/logo-text-light.svg";
import darkLogo from "@/assets/images/logo-text-dark.svg";
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() {
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 (
<div className="flex justify-center items-center flex-col h-[calc(100vh-170px)]">
<div>
<div className="flex h-[calc(100vh-170px)]">
<div className="flex flex-col items-center justify-center w-[70%] pr-10 text-[#999] text-sm">
<img
src={logo}
className="w-48 dark:text-white"
src={isDark ? darkLogo : lightLogo}
className="h-14"
alt={t("settings.about.logo")}
/>
</div>
<div className="mt-8 font-medium text-gray-900 dark:text-gray-100">
<div className="mt-4 text-base font-medium text-[#333] dark:text-white/80">
{t("settings.about.slogan")}
</div>
<div className="flex justify-center items-center mt-10">
<button
onClick={() => OpenURLWithBrowser("https://coco.rs")}
className="w-6 h-6 mr-2.5 flex justify-center rounded-[6px] border-[1px] gray-200 dark:border-gray-700"
aria-label={t("settings.about.website")}
>
<Globe className="w-3 text-blue-500" />
</button>
<button
onClick={() =>
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"
aria-label={t("settings.about.github")}
>
<Github className="w-3 text-blue-500" />
</button>
</div>
<div className="mt-8 text-sm text-gray-500 dark:text-gray-400">
<div className="mt-10">
{t("settings.about.version", {
version: process.env.VERSION || "N/A",
})}
</div>
<div className="mt-4 text-sm text-gray-500 dark:text-gray-400">
<div className="mt-3">
{t("settings.about.copyright", { year: new Date().getFullYear() })}
</div>
</div>
<div className="flex-1 flex flex-col items-center justify-center gap-4 pl-10 border-l border-[#e5e5e5] dark:border-[#4e4e56]">
{links.map((item) => {
const { icon, label } = item;
return (
<div
key={label}
className="flex items-center justify-between w-full"
>
<div className="flex items-center gap-2">
{cloneElement(icon, {
className: "size-4 text-[#999]",
})}
<span
className="text-[#333] dark:text-white/80 cursor-pointer hover:text-[#027FFE] transition"
onClick={() => {
handleClick(item);
}}
>
{label}
</span>
</div>
<SquareArrowOutUpRight
className="text-[#027FFE] size-4 cursor-pointer"
onClick={() => {
handleClick(item);
}}
/>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,23 +1,13 @@
import SettingsInput from "@/components/Settings/SettingsInput";
import SettingsItem from "@/components/Settings/SettingsItem";
import { useAppearanceStore } from "@/stores/appearanceStore";
import platformAdapter from "@/utils/platformAdapter";
import { AppWindowMac } from "lucide-react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
const Appearance = () => {
const { t } = useTranslation();
const opacity = useAppearanceStore((state) => state.opacity);
const setOpacity = useAppearanceStore((state) => state.setOpacity);
useEffect(() => {
const unlisten = useAppearanceStore.subscribe((state) => {
platformAdapter.emitEvent("change-appearance-store", state);
});
return unlisten;
}, []);
const { normalOpacity, setNormalOpacity, blurOpacity, setBlurOpacity } =
useAppearanceStore();
return (
<>
@@ -27,16 +17,34 @@ const Appearance = () => {
<SettingsItem
icon={AppWindowMac}
title={t("settings.advanced.appearance.opacity.title")}
description={t("settings.advanced.appearance.opacity.description")}
title={t("settings.advanced.appearance.normalOpacity.title")}
description={t(
"settings.advanced.appearance.normalOpacity.description"
)}
>
<SettingsInput
type="number"
min={10}
max={100}
value={opacity}
value={normalOpacity}
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>

View File

@@ -289,7 +289,7 @@ const Shortcuts = () => {
<Button
disabled={disabled}
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,
"opacity-70 cursor-not-allowed": disabled,

View File

@@ -1,8 +1,10 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
AppWindowMac,
ArrowUpWideNarrow,
MessageSquareMore,
PanelTopClose,
Search,
ShieldCheck,
Unplug,
@@ -18,6 +20,7 @@ import SettingsInput from "@/components//Settings/SettingsInput";
import platformAdapter from "@/utils/platformAdapter";
import UpdateSettings from "./components/UpdateSettings";
import SettingsToggle from "../SettingsToggle";
import { isNil } from "lodash-es";
const Advanced = () => {
const { t } = useTranslation();
@@ -57,6 +60,14 @@ const Advanced = () => {
const setAllowSelfSignature = useConnectStore((state) => {
return state.setAllowSelfSignature;
});
const {
searchDelay,
setSearchDelay,
compactModeAutoCollapseDelay,
setCompactModeAutoCollapseDelay,
} = useConnectStore();
const [localSearchResultWeight, setLocalSearchResultWeight] = useState(1);
useMount(async () => {
const allowSelfSignature = await platformAdapter.invokeBackend<boolean>(
@@ -64,6 +75,12 @@ const Advanced = () => {
);
setAllowSelfSignature(allowSelfSignature);
const weight = await platformAdapter.invokeBackend<number>(
"get_local_query_source_weight"
);
setLocalSearchResultWeight(weight);
});
useEffect(() => {
@@ -174,16 +191,20 @@ const Advanced = () => {
<Shortcuts />
<Appearance />
<UpdateSettings />
<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>
<div className="space-y-6">
<SettingsItem
icon={Unplug}
title={t("settings.advanced.connect.connectionTimeout.title")}
title={t("settings.advanced.other.connectionTimeout.title")}
description={t(
"settings.advanced.connect.connectionTimeout.description"
"settings.advanced.other.connectionTimeout.description"
)}
>
<SettingsInput
@@ -198,8 +219,8 @@ const Advanced = () => {
<SettingsItem
icon={Unplug}
title={t("settings.advanced.connect.queryTimeout.title")}
description={t("settings.advanced.connect.queryTimeout.description")}
title={t("settings.advanced.other.queryTimeout.title")}
description={t("settings.advanced.other.queryTimeout.description")}
>
<SettingsInput
type="number"
@@ -211,15 +232,30 @@ const Advanced = () => {
/>
</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
icon={ShieldCheck}
title={t("settings.advanced.connect.allowSelfSignature.title")}
title={t("settings.advanced.other.allowSelfSignature.title")}
description={t(
"settings.advanced.connect.allowSelfSignature.description"
"settings.advanced.other.allowSelfSignature.description"
)}
>
<SettingsToggle
label={t("settings.advanced.connect.allowSelfSignature.title")}
label={t("settings.advanced.other.allowSelfSignature.title")}
checked={allowSelfSignature}
onChange={(value) => {
setAllowSelfSignature(value);
@@ -230,11 +266,62 @@ const Advanced = () => {
}}
/>
</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>
<Appearance />
<UpdateSettings />
</div>
);
};

View File

@@ -1,5 +1,5 @@
import { FC, MouseEvent, useContext, useMemo, useState } from "react";
import { useReactive } from "ahooks";
import { useMount, useReactive } from "ahooks";
import { ChevronRight, LoaderCircle } from "lucide-react";
import clsx from "clsx";
import { isArray, startCase, sortBy } from "lodash-es";
@@ -20,11 +20,12 @@ const Content = () => {
return rootState.extensions.map((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;
parentId?: ExtensionId;
parentDeveloper?: string;
@@ -42,19 +43,8 @@ const subExtensionCommand: Partial<Record<ExtensionId, string>> = {
};
const Item: FC<ItemProps> = (props) => {
const {
id,
icon,
name,
type,
level,
platforms,
developer,
enabled,
parentId,
parentDeveloper,
parentDisabled,
} = props;
const { extension, level, parentId, parentDeveloper, parentDisabled } = props;
const { id, icon, name, type, platforms, developer, enabled } = extension;
const { rootState } = useContext(ExtensionsContext);
const state = useReactive<ItemState>({
loading: false,
@@ -63,6 +53,18 @@ const Item: FC<ItemProps> = (props) => {
const { t } = useTranslation();
const { disabledExtensions, setDisabledExtensions } = useExtensionsStore();
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 = {
developer: developer ?? parentDeveloper,
@@ -71,7 +73,7 @@ const Item: FC<ItemProps> = (props) => {
};
const hasSubExtensions = () => {
const { commands, scripts, quicklinks } = props;
const { commands, scripts, quicklinks } = extension;
if (subExtensionCommand[id]) {
return true;
@@ -87,7 +89,7 @@ const Item: FC<ItemProps> = (props) => {
const getSubExtensions = async () => {
state.loading = true;
const { commands, scripts, quicklinks } = props;
const { commands, scripts, quicklinks } = extension;
let subExtensions: Extension[] = [];
@@ -117,12 +119,16 @@ const Item: FC<ItemProps> = (props) => {
};
const isDisabled = useMemo(() => {
if (!compatible) {
return true;
}
if (level === 1) {
return selfDisabled;
}
return parentDisabled || selfDisabled;
}, [parentDisabled, selfDisabled]);
}, [parentDisabled, selfDisabled, compatible]);
const editable = useMemo(() => {
return (
@@ -134,7 +140,7 @@ const Item: FC<ItemProps> = (props) => {
}, [type]);
const renderAlias = () => {
const { alias } = props;
const { alias } = extension;
const handleChange = (value: string) => {
platformAdapter.invokeBackend("set_extension_alias", {
@@ -173,7 +179,7 @@ const Item: FC<ItemProps> = (props) => {
};
const renderHotkey = () => {
const { hotkey } = props;
const { hotkey } = extension;
const handleChange = (value: string) => {
if (value) {
@@ -246,7 +252,7 @@ const Item: FC<ItemProps> = (props) => {
return (
<div
className={clsx("flex items-center justify-end", {
"opacity-50 pointer-events-none": parentDisabled,
"opacity-50 pointer-events-none": !compatible || parentDisabled,
})}
>
<SettingsToggle
@@ -286,7 +292,7 @@ const Item: FC<ItemProps> = (props) => {
return (
<>
<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":
id === rootState.activeExtension?.id,
})}
@@ -294,7 +300,7 @@ const Item: FC<ItemProps> = (props) => {
<div
className="flex items-center justify-between gap-2 h-8"
onClick={() => {
rootState.activeExtension = props;
rootState.activeExtension = extension;
}}
>
<div
@@ -328,7 +334,7 @@ const Item: FC<ItemProps> = (props) => {
) : (
<img
src={platformAdapter.convertFileSrc(icon)}
className="size-full"
className="size-full dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
/>
)}
</div>
@@ -356,7 +362,7 @@ const Item: FC<ItemProps> = (props) => {
return (
<Item
key={item.id}
{...item}
extension={item}
level={level + 1}
parentId={id}
parentDeveloper={developer}

View File

@@ -83,7 +83,7 @@ const Applications = () => {
</div>
<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}
>
{t("settings.extensions.application.details.reindex")}

View File

@@ -112,7 +112,7 @@ const DirectoryScope: FC<DirectoryScopeProps> = (props) => {
)}
<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}
>
{t("settings.extensions.directoryScope.button.addDirectories")}

View File

@@ -115,7 +115,7 @@ const FileSearch = () => {
{t("settings.extensions.fileSearch.label.searchFileTypes")}
</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) => {
return (
<div

View File

@@ -1,4 +1,4 @@
import { useContext } from "react";
import { useContext, useState } from "react";
import { ExtensionsContext } from "../..";
import Applications from "./Applications";
@@ -8,11 +8,12 @@ import SharedAi from "./SharedAi";
import AiOverview from "./AiOverview";
import Calculator from "./Calculator";
import FileSearch from "./FileSearch";
import { Ellipsis } from "lucide-react";
import { Ellipsis, Info } from "lucide-react";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import platformAdapter from "@/utils/platformAdapter";
import { useAppStore } from "@/stores/appStore";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "ahooks";
const Details = () => {
const { rootState } = useContext(ExtensionsContext);
@@ -33,6 +34,23 @@ const Details = () => {
});
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 = () => {
if (!rootState.activeExtension) return;
@@ -77,7 +95,7 @@ const Details = () => {
return (
<div className="flex-1 h-full pr-4 pb-4 overflow-auto">
<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}
</h2>
@@ -130,6 +148,16 @@ const Details = () => {
)}
</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>
);

View File

@@ -13,6 +13,7 @@ import Details from "./components/Details";
import { useExtensionsStore } from "@/stores/extensionsStore";
import SettingsInput from "../SettingsInput";
import { useAppStore } from "@/stores/appStore";
import { installExtensionError } from "@/utils";
export type ExtensionId = LiteralUnion<
| "Applications"
@@ -32,7 +33,9 @@ type ExtensionType =
| "setting"
| "calculator"
| "command"
| "ai_extension";
| "ai_extension"
| "view"
| "unknown";
export type ExtensionPlatform = "windows" | "macos" | "linux";
@@ -62,6 +65,12 @@ export interface ExtensionPermission {
api: string[] | null;
}
export interface ViewExtensionUISettings {
search_bar: boolean;
filter_bar: boolean;
footer: boolean;
}
export interface Extension {
id: ExtensionId;
type: ExtensionType;
@@ -137,6 +146,8 @@ export const Extensions = () => {
}
);
console.log("extensions", cloneDeep(extensions));
state.extensions = sortBy(extensions, ["name"]);
if (configId) {
@@ -181,7 +192,7 @@ export const Extensions = () => {
</h2>
<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]" />
</MenuButton>
@@ -222,21 +233,7 @@ export const Extensions = () => {
"info"
);
} catch (error) {
const errorMessage = 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"));
}
installExtensionError(String(error));
}
}}
>
@@ -248,7 +245,7 @@ export const Extensions = () => {
</div>
<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) => {
return (
<div

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, cloneElement, ReactElement } from "react";
import {
Command,
Monitor,
@@ -9,6 +9,9 @@ import {
Tags,
// Trash2,
Globe,
PictureInPicture2,
PanelTop,
RectangleHorizontal,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { isTauri } from "@tauri-apps/api/core";
@@ -31,6 +34,8 @@ import {
unregister_shortcut,
} from "@/commands";
import platformAdapter from "@/utils/platformAdapter";
import clsx from "clsx";
import { useAppearanceStore, WindowMode } from "@/stores/appearanceStore";
export function ThemeOption({
icon: Icon,
@@ -76,6 +81,7 @@ export default function GeneralSettings() {
const [launchAtLogin, setLaunchAtLogin] = useState(true);
const { showTooltip, setShowTooltip, language, setLanguage } = useAppStore();
const { windowMode, setWindowMode } = useAppearanceStore();
const fetchAutoStartStatus = async () => {
if (isTauri()) {
@@ -176,6 +182,20 @@ export default function GeneralSettings() {
const currentLanguage = language || i18n.language;
const windowModes: Array<{
icon: ReactElement;
value: WindowMode;
}> = [
{
icon: <PanelTop />,
value: "default",
},
{
icon: <RectangleHorizontal />,
value: "compact",
},
];
return (
<div className="space-y-8">
<div>
@@ -239,6 +259,52 @@ export default function GeneralSettings() {
/>
</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
icon={Globe}
title={t("settings.language.title")}

View File

@@ -36,7 +36,7 @@ const SettingsInput: FC<SettingsInputProps> = (props) => {
{...rest}
autoCorrect="off"
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
)}
onBlur={handleBlur}

View File

@@ -4,7 +4,7 @@ interface SettingsItemProps {
icon: LucideIcon;
title: string;
description: string;
children: React.ReactNode;
children?: React.ReactNode;
}
export default function SettingsItem({
@@ -14,7 +14,7 @@ export default function SettingsItem({
children,
}: SettingsItemProps) {
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">
<Icon className="h-5 min-w-5 text-gray-400 dark:text-gray-500" />
<div>

View File

@@ -47,7 +47,7 @@ const SettingsSelectPro: FC<SettingsSelectProProps> = (props) => {
return (
<div ref={containerRef} className="relative">
<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}
>
{option?.[labelField] ?? (
@@ -57,7 +57,7 @@ const SettingsSelectPro: FC<SettingsSelectProProps> = (props) => {
<div
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,
}
@@ -83,7 +83,7 @@ const SettingsSelectPro: FC<SettingsSelectProProps> = (props) => {
<div
key={item?.[valueField] ?? index}
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]":
value === item?.[valueField],

View File

@@ -133,7 +133,6 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
if (updateInfo?.version) {
setSkipVersions([...skipVersions, updateInfo.version]);
}
isCheckPage ? hide_check() : setVisible(false);
@@ -143,6 +142,7 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
<Dialog
open={isCheckPage ? true : visible}
as="div"
id="update-app-dialog"
className="relative z-10 focus:outline-none"
onClose={noop}
>
@@ -154,6 +154,7 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
}`}
>
<div
data-tauri-drag-region
className={clsx(
"flex min-h-full items-center justify-center",
!isCheckPage && "p-4"
@@ -161,11 +162,13 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
>
<DialogPanel
transition
className={`relative w-[340px] py-8 flex flex-col items-center ${
isCheckPage
? ""
: "rounded-lg bg-white dark:bg-[#333] border border-[#EDEDED] dark:border-black/20 shadow-md"
}`}
className={clsx(
"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":
!isCheckPage,
}
)}
>
{!isCheckPage && isOptional && (
<X
@@ -238,7 +241,7 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
)}
</Button>
{!isCheckPage && updateInfo && isOptional && (
{updateInfo && isOptional && (
<div
className={clsx("text-xs text-[#999]", cursorClassName)}
onClick={handleSkip}

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

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

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

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

View File

@@ -0,0 +1,57 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[6px] text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-[6px] px-3 text-xs",
lg: "h-10 rounded-[6px] px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

Some files were not shown because too many files have changed in this diff Show More