feat: integrate Google semantic search and branded desktop launcher
Core CI / core (ubuntu-latest) (push) Failing after 3m27s
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s
Core CI / desktop (ubuntu-latest) (push) Successful in 5m45s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s
Core CI / core (ubuntu-latest) (push) Failing after 3m27s
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s
Core CI / desktop (ubuntu-latest) (push) Successful in 5m45s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s
This commit is contained in:
1 parent
a90df15503
commit
caf6a2f82e
80 files changed
+14971
-837
No files matched your search
+31
-2
@@ -1,6 +1,6 @@
|
||||
# Searchi desktop
|
||||
|
||||
This Tauri 2 shell calls the same Rust core as the CLI. It provides real folder indexing, native folder selection, filename/content search, folder/extension/size/date filters, keyboard navigation, escaped text previews, and native open/reveal actions. There are no mocked results or embeddings. Semantic retrieval, a global shortcut, watchers, and release installers remain roadmap work.
|
||||
This Tauri 2 shell calls the same Rust core as the CLI and the real Google EmbeddingGemma 2 service. It provides folder indexing, native folder selection, progressive filename/content/semantic search, folder/extension/size/date filters, keyboard navigation, escaped text previews, native open/reveal actions, persisted selected folders/resource settings, and a global shortcut. There are no mocked results or embeddings. Watchers and release installers remain roadmap work.
|
||||
|
||||
## Develop
|
||||
|
||||
@@ -15,15 +15,44 @@ npm run tauri dev
|
||||
|
||||
`npm run dev` previews layout in a browser but cannot index or search files. The app reports this limitation explicitly. Press Ctrl+K (Cmd+K on macOS) to focus search; arrow keys navigate results. Choose a folder through the native dialog or enter an absolute path. Open/reveal commands canonicalize the path, verify it is a current file, and require exact membership in the index. The webview has no general opener or filesystem capability. Page/paragraph source locations are displayed when supplied by the core; opening at that exact location is future work.
|
||||
|
||||
The desktop index is stored in the platform app-data directory under `dev.searchi.desktop`. Search opens independent core connections while indexing runs on a blocking worker; a guard limits this shell to one indexing job at a time. All model testing on the Windows VM must remain CPU only. This shell configures no GPU provider.
|
||||
Ctrl+Shift+Space shows and focuses the running app on Windows/Linux; Shift+Cmd+Space does so on macOS. Registration failures appear under Folders & resources rather than preventing startup. Wayland sessions explicitly report the shortcut as unsupported; launcher access remains available. Registration uses the [official Tauri global shortcut plugin](https://v2.tauri.app/plugin/global-shortcut/). Closing the app exits it; background tray lifecycle is not implemented.
|
||||
|
||||
Selected roots and the CPU thread budget are stored in versioned `settings.json` beside the index. Writes use an atomic temporary-file replacement; invalid values never overwrite previous settings. Missing files use defaults, while corrupted/unsupported settings fail explicitly. Selected roots may remain listed while disconnected and are reindexed only on user request. The CPU budget defaults to one and is limited to eight. Indexing uses one worker; CPU inference respects the saved budget.
|
||||
|
||||
The desktop index is stored in the platform app-data directory under `dev.searchi.desktop`. Search uses a cached core reader while indexing runs on a separate blocking worker; a guard limits this shell to one indexing job at a time. Production inference automatically selects supported acceleration with CPU fallback. All model testing on the Windows VM must remain CPU only.
|
||||
|
||||
## Automatic local semantic search
|
||||
|
||||
On first launch, the native app downloads and verifies the pinned Google model artifacts (310 MB text model plus 555 MB media projector, approximately 865 MB total) into app-managed storage and prepares the packaged embedding worker. No model paths, runtime paths, or terminal setup are required. The UI shows native download bytes, verification/warming states, the actual worker device, and explicit errors with a retry control. The first download requires network access; file extraction, embeddings, and queries stay local.
|
||||
|
||||
After a folder is indexed, semantic indexing starts automatically when the model is ready. Existing keyword results stay searchable during setup/indexing. Progress is indeterminate during embedding because the native command does not expose a chunk completion count. Failed automatic builds retry only after a changed index/status revision or an explicit rebuild, avoiding repeated failure loops. Status polls run once per second while setup/indexing/warming is active and stop when idle.
|
||||
|
||||
Each query captures one immutable filter snapshot. Keyword results appear first; real hybrid results follow after the query settles. `src/search-state.ts` bounds each lane to one active native request and one latest pending request, discards old revisions, and retries a busy request at most once. Escape clears both pending lanes and their timers; already-running native work finishes without changing cleared results. Hybrid updates preserve the selected file and keyboard focus where possible.
|
||||
|
||||
Run the async-state tests with Node.js 24 or newer:
|
||||
|
||||
```sh
|
||||
node --test tests/search-state.test.mjs
|
||||
```
|
||||
|
||||
For Windows VM UI testing, disable WebView2 GPU rendering as well, before starting Tauri in PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = '--disable-gpu'
|
||||
$env:SEARCHI_FORCE_CPU = '1'
|
||||
npm.cmd run tauri dev
|
||||
```
|
||||
|
||||
## Desktop design
|
||||
|
||||
The compact launcher follows the selected first visual direction with the searchi brand palette: blue `#2563FF`, dark `#0B0F14`, light `#F8FAFC`, lowercase wordmark, and “Find what matters. Locally.” Native operating-system window chrome remains native. The right pane shows real extracted passages and source locations, without invented PDF pagination or confidence scores.
|
||||
|
||||
Inter Variable is bundled locally with system font fallbacks. All interface icons come from Microsoft Fluent UI System Icons; no runtime CDN requests are made. Light/dark themes follow the operating system. Brief 120–160 ms opacity/transform motion respects reduced-motion settings, and forced-colors mode retains borders and focus visibility. Keyed result rows preserve selected files, keyboard focus, and scroll position during hybrid updates. Settings, filters, and model details use compact disclosures; errors open model details so recovery controls stay accessible.
|
||||
|
||||
Distributed notices are included in `public/licenses/` and `src-tauri/resources/licenses/`: [Inter SIL Open Font License](public/licenses/Inter-OFL.txt), [Microsoft Fluent icons MIT license](public/licenses/Fluent-MIT.txt). These are the actual upstream notices, not summaries.
|
||||
|
||||
Visual fidelity remains subject to native screenshot QA. Compilation and state tests alone do not verify visual appearance.
|
||||
|
||||
## Verification and packaging
|
||||
|
||||
`npm run build` checks strict TypeScript and creates production frontend assets. `npm run tauri build -- --no-bundle` compiles the native shell when platform prerequisites are installed. Installer bundling is intentionally disabled until release icons, signing and native platform checks are complete. Native Windows, macOS, and Linux checks must be recorded separately; a frontend build does not prove the native shell works.
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Searchi</title>
|
||||
<title>searchi</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Generated
+17
@@ -8,6 +8,8 @@
|
||||
"name": "searchi-desktop",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fluentui/svg-icons": "1.1.344",
|
||||
"@fontsource-variable/inter": "5.3.0",
|
||||
"@tauri-apps/api": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -459,6 +461,21 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fluentui/svg-icons": {
|
||||
"version": "1.1.344",
|
||||
"resolved": "https://registry.npmjs.org/@fluentui/svg-icons/-/svg-icons-1.1.344.tgz",
|
||||
"integrity": "sha512-rasjaq4hBXzRS25pSY9gaqwY7NdvZJCz7G/rtpwF6mCmg2R+9TnySLhHbR+sOpkMP/IDDG28XvxRcNEQ/lJYoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fontsource-variable/inter": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
|
||||
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||
|
||||
@@ -6,11 +6,14 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"tauri": "tauri",
|
||||
"format": "prettier --write src index.html vite.config.ts tsconfig.json src-tauri/tauri.conf.json src-tauri/capabilities/default.json",
|
||||
"format:check": "prettier --check src index.html vite.config.ts tsconfig.json src-tauri/tauri.conf.json src-tauri/capabilities/default.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluentui/svg-icons": "1.1.344",
|
||||
"@fontsource-variable/inter": "5.3.0",
|
||||
"@tauri-apps/api": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Microsoft Corporation
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 602 KiB |
Generated
+630
-3
@@ -79,6 +79,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
@@ -535,6 +541,12 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
@@ -568,6 +580,43 @@ dependencies = [
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||
|
||||
[[package]]
|
||||
name = "codespan-reporting"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"termcolor",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.8"
|
||||
@@ -745,6 +794,68 @@ version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d"
|
||||
|
||||
[[package]]
|
||||
name = "cxx"
|
||||
version = "1.0.205"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8a02bfeba9074b2bbb39492c9b0908ec854f455a3cccdcde7a1de55e2315e40"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cxx-build",
|
||||
"cxxbridge-cmd",
|
||||
"cxxbridge-flags",
|
||||
"cxxbridge-macro",
|
||||
"foldhash",
|
||||
"link-cplusplus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cxx-build"
|
||||
version = "1.0.205"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbc2ac76997619d2d888b58ad79488f1c835de41ca4dd3dd7082acf4ecb0a50f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"codespan-reporting",
|
||||
"indexmap 2.14.2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"scratch",
|
||||
"syn 3.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cxxbridge-cmd"
|
||||
version = "1.0.205"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54a486ed16a3f2a053fe7f5b98db4c1a59846a23e8cb9e49f9d1bd48830d51a2"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"codespan-reporting",
|
||||
"indexmap 2.14.2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cxxbridge-flags"
|
||||
version = "1.0.205"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c72569be94e2283764d334cd285980b747dbe221e27e315484eb6bcd93fa808a"
|
||||
|
||||
[[package]]
|
||||
name = "cxxbridge-macro"
|
||||
version = "1.0.205"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "423e795284a7f7b3f843c956a5322c4bc497b60c2dd1a6339da10313cb18d575"
|
||||
dependencies = [
|
||||
"indexmap 2.14.2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.24.1"
|
||||
@@ -872,6 +983,15 @@ dependencies = [
|
||||
"crypto-common 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "directories"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "7.0.0"
|
||||
@@ -1209,6 +1329,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.34"
|
||||
@@ -1216,6 +1346,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1402,6 +1533,29 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
@@ -1421,9 +1575,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1511,6 +1667,24 @@ version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||
|
||||
[[package]]
|
||||
name = "global-hotkey"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"keyboard-types 0.7.0",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
"windows-sys 0.59.0",
|
||||
"x11rb",
|
||||
"xkeysym",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gobject-sys"
|
||||
version = "0.18.0"
|
||||
@@ -1705,6 +1879,22 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.21"
|
||||
@@ -2100,6 +2290,17 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyboard-types"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"serde",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyboard-types"
|
||||
version = "0.8.3"
|
||||
@@ -2179,6 +2380,15 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "link-cplusplus"
|
||||
version = "1.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -2233,6 +2443,12 @@ dependencies = [
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.39.0"
|
||||
@@ -2315,7 +2531,7 @@ dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dpi",
|
||||
"gtk",
|
||||
"keyboard-types",
|
||||
"keyboard-types 0.8.3",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
@@ -2415,6 +2631,15 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numkong"
|
||||
version = "7.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53bcc7469814b82ddffa194dbf3418fc28fa49015392261926f85c5f1cae0625"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
version = "0.6.5"
|
||||
@@ -2941,6 +3166,62 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.21",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
@@ -2979,6 +3260,15 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rangemap"
|
||||
version = "1.8.0"
|
||||
@@ -3059,6 +3349,46 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.5"
|
||||
@@ -3117,6 +3447,20 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
@@ -3159,12 +3503,53 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -3231,6 +3616,25 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "scratch"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2"
|
||||
|
||||
[[package]]
|
||||
name = "searchi-assets"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"directories",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "searchi-core"
|
||||
version = "0.1.0"
|
||||
@@ -3248,15 +3652,43 @@ dependencies = [
|
||||
name = "searchi-desktop"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"searchi-assets",
|
||||
"searchi-core",
|
||||
"searchi-service",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-global-shortcut",
|
||||
"tauri-plugin-opener",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "searchi-semantic"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "searchi-service"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fs2",
|
||||
"rusqlite",
|
||||
"searchi-core",
|
||||
"searchi-semantic",
|
||||
"serde",
|
||||
"sha2 0.10.9",
|
||||
"usearch",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.38.0"
|
||||
@@ -3381,6 +3813,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.24.0"
|
||||
@@ -3618,6 +4062,12 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.9"
|
||||
@@ -3781,7 +4231,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"reqwest 0.13.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -3922,6 +4372,21 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-global-shortcut"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93ff17919fe09852d269bd37b1d3d2e993b9dbb514afe7acbf3346c1d3627e2d"
|
||||
dependencies = [
|
||||
"global-hotkey",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.7.1"
|
||||
@@ -4066,6 +4531,15 @@ dependencies = [
|
||||
"new_debug_unreachable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -4166,6 +4640,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.20"
|
||||
@@ -4441,6 +4925,18 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -4466,6 +4962,17 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usearch"
|
||||
version = "2.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1086fc5e6b4ee87a5bac4fd80fcf66670526ca21b77d5f95b68de01b15329c67"
|
||||
dependencies = [
|
||||
"cxx",
|
||||
"cxx-build",
|
||||
"numkong",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -4701,6 +5208,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.39.1"
|
||||
@@ -4898,6 +5414,24 @@ dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
@@ -4931,6 +5465,22 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
@@ -4941,7 +5491,7 @@ dependencies = [
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_gnullvm 0.53.1",
|
||||
"windows_i686_msvc 0.53.1",
|
||||
"windows_x86_64_gnu 0.53.1",
|
||||
"windows_x86_64_gnullvm 0.53.1",
|
||||
@@ -4972,6 +5522,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
@@ -4984,6 +5540,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
@@ -4996,12 +5558,24 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
@@ -5014,6 +5588,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
@@ -5026,6 +5606,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
@@ -5038,6 +5624,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
@@ -5050,6 +5642,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
@@ -5161,6 +5759,29 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
||||
dependencies = [
|
||||
"gethostname",
|
||||
"rustix",
|
||||
"x11rb-protocol",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb-protocol"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "xkeysym"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
@@ -5295,6 +5916,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
|
||||
@@ -17,8 +17,10 @@ tauri-build = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
searchi-core = { path = "../../../crates/searchi-core" }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
tempfile = "3"
|
||||
searchi-core = { path = "../../../crates/searchi-core" }
|
||||
searchi-service = { path = "../../../crates/searchi-service" }
|
||||
searchi-assets = { path = "../../../crates/searchi-assets" }
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023-2026 The ggml authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,3 @@
|
||||
Searchi's native Google embedding worker is staged here during application builds.
|
||||
The installer ships that worker; Google model files are automatically verified
|
||||
and downloaded to the local model cache on first launch when not bundled.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Microsoft Corporation
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -1,21 +1,75 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod model_download;
|
||||
mod semantic_commands;
|
||||
mod settings;
|
||||
|
||||
use searchi_core::{IndexReport, IndexStats, SearchIndex, SearchOptions, SearchResult};
|
||||
use serde::Deserialize;
|
||||
use semantic_commands::{
|
||||
hybrid_search, index_embeddings, semantic_status, setup_model, SemanticState,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{Settings, SettingsStore};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
Arc, Mutex,
|
||||
},
|
||||
};
|
||||
use tauri::{Manager, State};
|
||||
use tauri::{Emitter, Manager, State};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
struct AppState {
|
||||
database: PathBuf,
|
||||
lexical: Arc<Mutex<SearchIndex>>,
|
||||
indexing: Arc<AtomicBool>,
|
||||
settings: Arc<Mutex<SettingsStore>>,
|
||||
shortcut_status: String,
|
||||
semantic: Arc<SemanticState>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DesktopStatus {
|
||||
settings: Settings,
|
||||
indexing: bool,
|
||||
shortcut_status: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn desktop_status(state: State<'_, AppState>) -> Result<DesktopStatus, String> {
|
||||
Ok(DesktopStatus {
|
||||
settings: state
|
||||
.settings
|
||||
.lock()
|
||||
.map_err(|_| "Settings are unavailable.")?
|
||||
.value
|
||||
.clone(),
|
||||
indexing: state.indexing.load(Ordering::Acquire),
|
||||
shortcut_status: state.shortcut_status.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_cpu_threads(threads: usize, state: State<'_, AppState>) -> Result<Settings, String> {
|
||||
if state.indexing.load(Ordering::Acquire) {
|
||||
return Err("Wait for indexing to finish before changing resource settings.".into());
|
||||
}
|
||||
let mut store = state
|
||||
.settings
|
||||
.lock()
|
||||
.map_err(|_| "Settings are unavailable.")?;
|
||||
let mut next = store.value.clone();
|
||||
next.cpu_threads = threads;
|
||||
next.validate()?;
|
||||
state.semantic.reset_session()?;
|
||||
store.save(next)?;
|
||||
state
|
||||
.semantic
|
||||
.begin_automatic_setup(state.database.clone(), threads);
|
||||
Ok(store.value.clone())
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
@@ -48,12 +102,12 @@ async fn search(
|
||||
filters: Option<Filters>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<SearchResult>, String> {
|
||||
let database = state.database.clone();
|
||||
let lexical = state.lexical.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
SearchIndex::open(database)
|
||||
.and_then(|index| {
|
||||
index.search_with_options(&query, 50, &filters.unwrap_or_default().into())
|
||||
})
|
||||
lexical
|
||||
.try_lock()
|
||||
.map_err(|_| "Keyword worker is busy; try the latest query again.".to_owned())?
|
||||
.search_with_options(&query, 50, &filters.unwrap_or_default().into())
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
@@ -150,11 +204,23 @@ async fn index_folder(root: String, state: State<'_, AppState>) -> Result<IndexR
|
||||
}
|
||||
let guard = IndexGuard(state.indexing.clone());
|
||||
let database = state.database.clone();
|
||||
let settings = state.settings.clone();
|
||||
let lexical = state.lexical.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _guard = guard;
|
||||
SearchIndex::open(database)
|
||||
.and_then(|mut index| index.index_folder(root))
|
||||
.map_err(|e| e.to_string())
|
||||
settings
|
||||
.lock()
|
||||
.map_err(|_| "Settings are unavailable.")?
|
||||
.add_root(std::path::Path::new(&root))?;
|
||||
let mut index = SearchIndex::open(database).map_err(|error| error.to_string())?;
|
||||
let report = index
|
||||
.index_folder(root)
|
||||
.map_err(|error| error.to_string())?;
|
||||
index.prewarm().map_err(|error| error.to_string())?;
|
||||
*lexical
|
||||
.lock()
|
||||
.map_err(|_| "Keyword engine is unavailable.")? = index;
|
||||
Ok(report)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
@@ -164,14 +230,78 @@ fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().with_handler(|app, _, event| {
|
||||
if event.state == ShortcutState::Pressed {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let result = window.show().and_then(|_| window.unminimize()).and_then(|_| window.set_focus());
|
||||
if let Err(error) = result { eprintln!("Could not focus Searchi: {error}"); }
|
||||
let _ = window.emit("focus-search", ());
|
||||
}
|
||||
}
|
||||
}).build())
|
||||
.setup(|app| {
|
||||
let directory = app.path().app_data_dir()?;
|
||||
std::fs::create_dir_all(&directory)?;
|
||||
let database = directory.join("searchi.sqlite");
|
||||
SearchIndex::open(&database)?;
|
||||
let lexical = Arc::new(Mutex::new(SearchIndex::open(&database)?));
|
||||
let warm_database = database.clone();
|
||||
let warm_target = lexical.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
// Prepare a separate reader and swap it in briefly; foreground
|
||||
// queries never wait on the long statistics-preparation lock.
|
||||
if let Ok(mut index) = SearchIndex::open(warm_database) {
|
||||
if index.prewarm().is_ok() {
|
||||
if let Ok(mut current) = warm_target.lock() { *current = index; }
|
||||
}
|
||||
}
|
||||
});
|
||||
let settings = SettingsStore::load(directory.join("settings.json")).map_err(std::io::Error::other)?;
|
||||
let development_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..");
|
||||
let development_models = development_root.join(".searchi/models/embeddinggemma2");
|
||||
let bundled_models = app.path().resource_dir()?.join("embeddinggemma2");
|
||||
let model_directory = settings.value.model_directory.clone().unwrap_or_else(|| {
|
||||
if cfg!(debug_assertions) && development_models.is_dir() {
|
||||
development_models.canonicalize().unwrap_or(development_models)
|
||||
} else if bundled_models.is_dir() {
|
||||
bundled_models
|
||||
} else {
|
||||
searchi_assets::default_model_directory().unwrap_or_else(|_| directory.join("Models"))
|
||||
}
|
||||
});
|
||||
let worker_name = if cfg!(windows) { "searchi-gemma-worker.exe" } else { "searchi-gemma-worker" };
|
||||
let development_worker = development_root.join(".searchi/runtime/cpu")
|
||||
.join(if cfg!(windows) { "Release" } else { "" }).join(worker_name);
|
||||
let worker = std::env::var_os("SEARCHI_EMBEDDING_WORKER").map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
if cfg!(debug_assertions) && development_worker.is_file() {
|
||||
development_worker.canonicalize().unwrap_or(development_worker)
|
||||
} else {
|
||||
app.path().resource_dir().unwrap_or_else(|_| directory.clone()).join(worker_name)
|
||||
}
|
||||
});
|
||||
let semantic = Arc::new(SemanticState::new(model_directory, worker));
|
||||
semantic.begin_automatic_setup(database.clone(), settings.value.cpu_threads);
|
||||
#[cfg(target_os = "macos")]
|
||||
let shortcut = "Shift+Super+Space";
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let shortcut = "Control+Shift+Space";
|
||||
let wayland = cfg!(target_os = "linux") &&
|
||||
(std::env::var("XDG_SESSION_TYPE").is_ok_and(|value| value == "wayland") || std::env::var_os("WAYLAND_DISPLAY").is_some());
|
||||
let shortcut_status = if wayland {
|
||||
"Global shortcut unavailable on Wayland. Open Searchi from your application launcher.".into()
|
||||
} else {
|
||||
match app.global_shortcut().register(shortcut) {
|
||||
Ok(()) => format!("Global shortcut: {shortcut}"),
|
||||
Err(error) => format!("Global shortcut unavailable: {error}"),
|
||||
}
|
||||
};
|
||||
app.manage(AppState {
|
||||
database,
|
||||
lexical,
|
||||
indexing: Arc::new(AtomicBool::new(false)),
|
||||
settings: Arc::new(Mutex::new(settings)),
|
||||
shortcut_status,
|
||||
semantic,
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
@@ -180,7 +310,13 @@ fn main() {
|
||||
stats,
|
||||
index_folder,
|
||||
pick_folder,
|
||||
open_result
|
||||
open_result,
|
||||
desktop_status,
|
||||
set_cpu_threads,
|
||||
semantic_status,
|
||||
setup_model,
|
||||
index_embeddings,
|
||||
hybrid_search
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("Searchi desktop could not start");
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Thin desktop adapter over the GUI-free, shared model provisioner.
|
||||
pub use searchi_assets::TOTAL_BYTES;
|
||||
use std::{path::Path, sync::atomic::AtomicU64};
|
||||
|
||||
pub fn provision(directory: &Path, progress: &AtomicU64) -> Result<(), String> {
|
||||
searchi_assets::provision(directory, progress).map_err(|error| format!("{error:#}"))
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Desktop-owned orchestration: managed model setup and persistent hybrid queries.
|
||||
use crate::{model_download, AppState, Filters};
|
||||
use searchi_core::SearchResult;
|
||||
use searchi_service::{EmbeddingReport, HybridSearch};
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
Mutex,
|
||||
},
|
||||
};
|
||||
use tauri::State;
|
||||
|
||||
pub struct SemanticState {
|
||||
pub model_directory: PathBuf,
|
||||
pub worker: PathBuf,
|
||||
verified: AtomicBool,
|
||||
downloading: AtomicBool,
|
||||
embedding: AtomicBool,
|
||||
querying: AtomicBool,
|
||||
warmed: AtomicBool,
|
||||
warming: AtomicBool,
|
||||
downloaded_bytes: AtomicU64,
|
||||
error: Mutex<Option<String>>,
|
||||
service: Mutex<Option<HybridSearch>>,
|
||||
}
|
||||
|
||||
impl SemanticState {
|
||||
pub fn new(model_directory: PathBuf, worker: PathBuf) -> Self {
|
||||
Self {
|
||||
model_directory,
|
||||
worker,
|
||||
verified: AtomicBool::new(false),
|
||||
downloading: AtomicBool::new(false),
|
||||
embedding: AtomicBool::new(false),
|
||||
querying: AtomicBool::new(false),
|
||||
warmed: AtomicBool::new(false),
|
||||
warming: AtomicBool::new(false),
|
||||
downloaded_bytes: AtomicU64::new(0),
|
||||
error: Mutex::new(None),
|
||||
service: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_session(&self) -> Result<(), String> {
|
||||
*self
|
||||
.service
|
||||
.try_lock()
|
||||
.map_err(|_| "Wait for the current semantic query before changing resources.")? = None;
|
||||
self.warmed.store(false, Ordering::Release);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare_query_session(&self, database: PathBuf, threads: usize) -> Result<(), String> {
|
||||
self.warming.store(true, Ordering::Release);
|
||||
let _guard = WorkGuard(&self.warming);
|
||||
if !self.worker.is_file() {
|
||||
return Err(
|
||||
"The installed embedding worker is missing. Repair or reinstall Searchi.".into(),
|
||||
);
|
||||
}
|
||||
let mut service =
|
||||
HybridSearch::open(database, &self.model_directory, &self.worker, threads)
|
||||
.map_err(|error| error.to_string())?;
|
||||
service.warmup().map_err(|error| error.to_string())?;
|
||||
*self
|
||||
.service
|
||||
.lock()
|
||||
.map_err(|_| "Semantic session is unavailable.")? = Some(service);
|
||||
self.warmed.store(true, Ordering::Release);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// First-launch setup is authorized product behavior; inference remains offline.
|
||||
pub fn begin_automatic_setup(self: &std::sync::Arc<Self>, database: PathBuf, threads: usize) {
|
||||
if self
|
||||
.downloading
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let semantic = self.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _guard = WorkGuard(&semantic.downloading);
|
||||
semantic.record_error(None);
|
||||
let result =
|
||||
model_download::provision(&semantic.model_directory, &semantic.downloaded_bytes);
|
||||
semantic.verified.store(result.is_ok(), Ordering::Release);
|
||||
let result = result.and_then(|_| semantic.prepare_query_session(database, threads));
|
||||
semantic.record_error(result.err());
|
||||
});
|
||||
}
|
||||
|
||||
fn record_error(&self, value: Option<String>) {
|
||||
if let Ok(mut error) = self.error.lock() {
|
||||
*error = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SemanticStatus {
|
||||
model: &'static str,
|
||||
ready: bool,
|
||||
model_directory: String,
|
||||
worker_ready: bool,
|
||||
downloading: bool,
|
||||
embedding: bool,
|
||||
device_policy: &'static str,
|
||||
error: Option<String>,
|
||||
downloaded_bytes: u64,
|
||||
total_bytes: u64,
|
||||
generation: Option<i64>,
|
||||
chunks: usize,
|
||||
stale: bool,
|
||||
pending: usize,
|
||||
warming: bool,
|
||||
worker_info: Option<searchi_service::WorkerInfo>,
|
||||
}
|
||||
|
||||
struct WorkGuard<'a>(&'a AtomicBool);
|
||||
impl Drop for WorkGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn semantic_status(state: State<'_, AppState>) -> Result<SemanticStatus, String> {
|
||||
let semantic = &state.semantic;
|
||||
let worker_ready = semantic.worker.is_file();
|
||||
let index =
|
||||
HybridSearch::embedding_status(&state.database).map_err(|error| error.to_string())?;
|
||||
Ok(SemanticStatus {
|
||||
model: "Google EmbeddingGemma 2",
|
||||
ready: worker_ready && semantic.warmed.load(Ordering::Acquire),
|
||||
model_directory: semantic.model_directory.to_string_lossy().into_owned(),
|
||||
worker_ready,
|
||||
downloading: semantic.downloading.load(Ordering::Acquire),
|
||||
embedding: semantic.embedding.load(Ordering::Acquire),
|
||||
device_policy: if std::env::var("SEARCHI_FORCE_CPU").as_deref() == Ok("1") {
|
||||
"cpu"
|
||||
} else {
|
||||
"auto"
|
||||
},
|
||||
error: semantic.error.lock().ok().and_then(|value| value.clone()),
|
||||
downloaded_bytes: semantic.downloaded_bytes.load(Ordering::Acquire),
|
||||
total_bytes: model_download::TOTAL_BYTES,
|
||||
generation: index.generation,
|
||||
chunks: index.chunks,
|
||||
stale: index.stale,
|
||||
pending: index.pending,
|
||||
warming: semantic.warming.load(Ordering::Acquire),
|
||||
worker_info: semantic
|
||||
.service
|
||||
.try_lock()
|
||||
.ok()
|
||||
.and_then(|service| service.as_ref().and_then(HybridSearch::worker_info)),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn setup_model(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let semantic = state.semantic.clone();
|
||||
let database = state.database.clone();
|
||||
let threads = state
|
||||
.settings
|
||||
.lock()
|
||||
.map_err(|_| "Settings are unavailable.")?
|
||||
.value
|
||||
.cpu_threads;
|
||||
if semantic
|
||||
.downloading
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_err()
|
||||
{
|
||||
return Err("Google model setup is already running.".into());
|
||||
}
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _guard = WorkGuard(&semantic.downloading);
|
||||
semantic.record_error(None);
|
||||
let result =
|
||||
model_download::provision(&semantic.model_directory, &semantic.downloaded_bytes);
|
||||
semantic.verified.store(result.is_ok(), Ordering::Release);
|
||||
let result = result.and_then(|_| semantic.prepare_query_session(database, threads));
|
||||
semantic.record_error(result.as_ref().err().cloned());
|
||||
result
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn index_embeddings(state: State<'_, AppState>) -> Result<EmbeddingReport, String> {
|
||||
let semantic = state.semantic.clone();
|
||||
if !semantic.verified.load(Ordering::Acquire) || !semantic.worker.is_file() {
|
||||
return Err("Set up the Google model and native worker first.".into());
|
||||
}
|
||||
let threads = state
|
||||
.settings
|
||||
.lock()
|
||||
.map_err(|_| "Settings are unavailable.")?
|
||||
.value
|
||||
.cpu_threads;
|
||||
if semantic
|
||||
.embedding
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_err()
|
||||
{
|
||||
return Err("Semantic indexing is already running.".into());
|
||||
}
|
||||
let database = state.database.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _guard = WorkGuard(&semantic.embedding);
|
||||
semantic.record_error(None);
|
||||
// Indexing uses its own isolated worker; interactive queries keep their own session.
|
||||
let result = HybridSearch::open(
|
||||
database,
|
||||
&semantic.model_directory,
|
||||
&semantic.worker,
|
||||
threads,
|
||||
)
|
||||
.and_then(|mut service| service.index_embeddings())
|
||||
.map_err(|error| error.to_string());
|
||||
semantic.record_error(result.as_ref().err().cloned());
|
||||
result
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hybrid_search(
|
||||
query: String,
|
||||
filters: Option<Filters>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<SearchResult>, String> {
|
||||
let semantic = state.semantic.clone();
|
||||
if !semantic.warmed.load(Ordering::Acquire) {
|
||||
return Err(
|
||||
"Semantic search is preparing in the background; keyword results are available.".into(),
|
||||
);
|
||||
}
|
||||
let database = state.database.clone();
|
||||
let threads = state
|
||||
.settings
|
||||
.lock()
|
||||
.map_err(|_| "Settings are unavailable.")?
|
||||
.value
|
||||
.cpu_threads;
|
||||
if semantic
|
||||
.querying
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_err()
|
||||
{
|
||||
return Err("Semantic worker is busy; keyword results remain available.".into());
|
||||
}
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _guard = WorkGuard(&semantic.querying);
|
||||
let mut cached = semantic
|
||||
.service
|
||||
.lock()
|
||||
.map_err(|_| "Semantic session is unavailable.")?;
|
||||
if cached.is_none() {
|
||||
*cached = Some(
|
||||
HybridSearch::open(
|
||||
database,
|
||||
&semantic.model_directory,
|
||||
&semantic.worker,
|
||||
threads,
|
||||
)
|
||||
.map_err(|error| error.to_string())?,
|
||||
);
|
||||
}
|
||||
cached
|
||||
.as_mut()
|
||||
.ok_or("Semantic session is unavailable.")?
|
||||
.search(&query, 50, &filters.unwrap_or_default().into())
|
||||
.map_err(|error| error.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Versioned local settings. Writes replace the destination atomically; failed
|
||||
//! saves leave both the previous file and in-memory settings intact.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs,
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
const SETTINGS_VERSION: u32 = 1;
|
||||
const MAX_SETTINGS_BYTES: u64 = 128 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Settings {
|
||||
pub version: u32,
|
||||
pub roots: Vec<PathBuf>,
|
||||
pub cpu_threads: usize,
|
||||
/// Managed local Google EmbeddingGemma2 artifacts. Older settings default
|
||||
/// to unconfigured; model acquisition is an explicit desktop action.
|
||||
#[serde(default)]
|
||||
pub model_directory: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: SETTINGS_VERSION,
|
||||
roots: Vec::new(),
|
||||
cpu_threads: 1,
|
||||
model_directory: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.version != SETTINGS_VERSION {
|
||||
return Err(format!("Unsupported settings version {}", self.version));
|
||||
}
|
||||
if !(1..=8).contains(&self.cpu_threads) {
|
||||
return Err("CPU threads must be between 1 and 8.".into());
|
||||
}
|
||||
if self.roots.len() > 64 {
|
||||
return Err("At most 64 indexed folders are supported.".into());
|
||||
}
|
||||
if let Some(directory) = &self.model_directory {
|
||||
if !directory.is_absolute() || directory.to_string_lossy().len() > 32768 {
|
||||
return Err("Model directory must be an absolute bounded path.".into());
|
||||
}
|
||||
}
|
||||
for (i, root) in self.roots.iter().enumerate() {
|
||||
if !root.is_absolute() {
|
||||
return Err("Indexed folder paths must be absolute.".into());
|
||||
}
|
||||
if root.to_string_lossy().len() > 32768 {
|
||||
return Err("Indexed folder path is too long.".into());
|
||||
}
|
||||
if self.roots[..i].contains(root) {
|
||||
return Err("Indexed folder paths must be unique.".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SettingsStore {
|
||||
path: PathBuf,
|
||||
pub value: Settings,
|
||||
}
|
||||
|
||||
impl SettingsStore {
|
||||
pub fn load(path: PathBuf) -> Result<Self, String> {
|
||||
let value = match fs::metadata(&path) {
|
||||
Ok(metadata) => {
|
||||
if metadata.len() > MAX_SETTINGS_BYTES {
|
||||
return Err("Settings file exceeds the size limit.".into());
|
||||
}
|
||||
serde_json::from_slice(&fs::read(&path).map_err(|e| e.to_string())?)
|
||||
.map_err(|e| format!("Invalid settings file: {e}"))?
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Settings::default(),
|
||||
Err(error) => return Err(error.to_string()),
|
||||
};
|
||||
let store = Self { path, value };
|
||||
store.value.validate()?;
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
pub fn save(&mut self, next: Settings) -> Result<(), String> {
|
||||
next.validate()?;
|
||||
let parent = self.path.parent().ok_or("Settings path has no parent.")?;
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?;
|
||||
let bytes = serde_json::to_vec_pretty(&next).map_err(|e| e.to_string())?;
|
||||
if bytes.len() as u64 > MAX_SETTINGS_BYTES {
|
||||
return Err("Settings exceed the size limit.".into());
|
||||
}
|
||||
temporary.write_all(&bytes).map_err(|e| e.to_string())?;
|
||||
temporary.as_file().sync_all().map_err(|e| e.to_string())?;
|
||||
temporary.persist(&self.path).map_err(|e| e.to_string())?;
|
||||
self.value = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_root(&mut self, root: &Path) -> Result<(), String> {
|
||||
let canonical = fs::canonicalize(root).map_err(|e| e.to_string())?;
|
||||
if !canonical.is_dir() {
|
||||
return Err("Indexed folder must be a directory.".into());
|
||||
}
|
||||
let mut next = self.value.clone();
|
||||
if !next.roots.contains(&canonical) {
|
||||
next.roots.push(canonical);
|
||||
self.save(next)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn settings_round_trip_and_atomic_replacement() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("settings.json");
|
||||
let mut store = SettingsStore::load(path.clone()).unwrap();
|
||||
assert_eq!(store.value.cpu_threads, 1);
|
||||
store.add_root(directory.path()).unwrap();
|
||||
store.add_root(directory.path()).unwrap();
|
||||
let mut next = store.value.clone();
|
||||
next.cpu_threads = 2;
|
||||
store.save(next.clone()).unwrap();
|
||||
assert_eq!(SettingsStore::load(path).unwrap().value, next);
|
||||
assert_eq!(store.value.roots.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_save_preserves_previous_file_and_memory() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("settings.json");
|
||||
let mut store = SettingsStore::load(path.clone()).unwrap();
|
||||
store.save(Settings::default()).unwrap();
|
||||
let original = fs::read(&path).unwrap();
|
||||
let mut invalid = store.value.clone();
|
||||
invalid.cpu_threads = 0;
|
||||
assert!(store.save(invalid).is_err());
|
||||
assert_eq!(store.value, Settings::default());
|
||||
assert_eq!(fs::read(&path).unwrap(), original);
|
||||
let mut invalid = store.value.clone();
|
||||
invalid.roots.push(PathBuf::from("relative"));
|
||||
assert!(store.save(invalid).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_corruption_unknown_version_and_excessive_threads() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("settings.json");
|
||||
fs::write(&path, "broken").unwrap();
|
||||
assert!(SettingsStore::load(path.clone()).is_err());
|
||||
fs::write(&path, r#"{"version":99,"roots":[],"cpu_threads":1}"#).unwrap();
|
||||
assert!(SettingsStore::load(path).is_err());
|
||||
let invalid = Settings {
|
||||
cpu_threads: 9,
|
||||
..Settings::default()
|
||||
};
|
||||
assert!(invalid.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_older_settings_with_unconfigured_google_model() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("settings.json");
|
||||
fs::write(&path, r#"{"version":1,"roots":[],"cpu_threads":1}"#).unwrap();
|
||||
let store = SettingsStore::load(path).unwrap();
|
||||
assert!(store.value.model_directory.is_none());
|
||||
let invalid = Settings {
|
||||
model_directory: Some(PathBuf::from("relative")),
|
||||
..Settings::default()
|
||||
};
|
||||
assert!(invalid.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_save_leaves_existing_settings_readable() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("settings.json");
|
||||
let mut store = SettingsStore::load(path.clone()).unwrap();
|
||||
store.save(Settings::default()).unwrap();
|
||||
let mut oversized = store.value.clone();
|
||||
oversized.roots = (0..64)
|
||||
.map(|i| directory.path().join(format!("{i}{}", "x".repeat(3000))))
|
||||
.collect();
|
||||
assert!(store.save(oversized).is_err());
|
||||
assert_eq!(
|
||||
SettingsStore::load(path).unwrap().value,
|
||||
Settings::default()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Searchi",
|
||||
"productName": "searchi",
|
||||
"version": "0.1.0",
|
||||
"identifier": "dev.searchi.desktop",
|
||||
"build": {
|
||||
@@ -12,7 +12,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Searchi",
|
||||
"title": "searchi",
|
||||
"width": 1080,
|
||||
"height": 760,
|
||||
"minWidth": 680,
|
||||
@@ -23,5 +23,15 @@
|
||||
"csp": "default-src 'self'; connect-src ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
|
||||
}
|
||||
},
|
||||
"bundle": { "active": false }
|
||||
"bundle": {
|
||||
"active": false,
|
||||
"resources": { "resources/": "" },
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/[email protected]",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Optimized library assets only. No handcrafted SVG or runtime icon requests.
|
||||
import search from "@fluentui/svg-icons/icons/search_24_regular.svg?raw";
|
||||
import settings from "@fluentui/svg-icons/icons/settings_20_regular.svg?raw";
|
||||
import filter from "@fluentui/svg-icons/icons/filter_20_regular.svg?raw";
|
||||
import dismiss from "@fluentui/svg-icons/icons/dismiss_20_regular.svg?raw";
|
||||
import folder from "@fluentui/svg-icons/icons/folder_24_regular.svg?raw";
|
||||
import folderAdd from "@fluentui/svg-icons/icons/folder_add_20_regular.svg?raw";
|
||||
import document from "@fluentui/svg-icons/icons/document_24_regular.svg?raw";
|
||||
import pdf from "@fluentui/svg-icons/icons/document_pdf_24_regular.svg?raw";
|
||||
import code from "@fluentui/svg-icons/icons/code_24_regular.svg?raw";
|
||||
import lock from "@fluentui/svg-icons/icons/lock_closed_20_regular.svg?raw";
|
||||
import arrowUp from "@fluentui/svg-icons/icons/arrow_up_16_regular.svg?raw";
|
||||
import arrowDown from "@fluentui/svg-icons/icons/arrow_down_16_regular.svg?raw";
|
||||
import open from "@fluentui/svg-icons/icons/open_20_regular.svg?raw";
|
||||
import chevron from "@fluentui/svg-icons/icons/chevron_down_16_regular.svg?raw";
|
||||
import sparkles from "@fluentui/svg-icons/icons/sparkle_20_regular.svg?raw";
|
||||
|
||||
const assets = {
|
||||
search,
|
||||
settings,
|
||||
filter,
|
||||
dismiss,
|
||||
folder,
|
||||
folderAdd,
|
||||
document,
|
||||
pdf,
|
||||
code,
|
||||
lock,
|
||||
arrowUp,
|
||||
arrowDown,
|
||||
open,
|
||||
chevron,
|
||||
sparkles,
|
||||
};
|
||||
export function icon(name: keyof typeof assets) {
|
||||
return `<span class="icon" aria-hidden="true">${assets[name]}</span>`;
|
||||
}
|
||||
export function fileIcon(filename: string) {
|
||||
const extension = filename.split(".").pop()?.toLowerCase();
|
||||
if (extension === "pdf") return { html: icon("pdf"), type: "pdf" };
|
||||
if (
|
||||
extension &&
|
||||
[
|
||||
"rs",
|
||||
"js",
|
||||
"ts",
|
||||
"tsx",
|
||||
"py",
|
||||
"json",
|
||||
"html",
|
||||
"css",
|
||||
"toml",
|
||||
"yaml",
|
||||
"yml",
|
||||
].includes(extension)
|
||||
)
|
||||
return { html: icon("code"), type: "code" };
|
||||
return {
|
||||
html: icon("document"),
|
||||
type: ["doc", "docx"].includes(extension || "")
|
||||
? "document-word"
|
||||
: "document",
|
||||
};
|
||||
}
|
||||
+583
-88
@@ -1,4 +1,13 @@
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import {
|
||||
AutomaticIndexGate,
|
||||
emptySearchState,
|
||||
LatestLane,
|
||||
} from "./search-state";
|
||||
import type { SemanticPhase } from "./search-state";
|
||||
import { fileIcon, icon } from "./icons";
|
||||
import "@fontsource-variable/inter/wght.css";
|
||||
import "./style.css";
|
||||
|
||||
interface SearchResult {
|
||||
@@ -20,16 +29,54 @@ interface IndexStats {
|
||||
files: number;
|
||||
content_files: number;
|
||||
}
|
||||
interface Settings {
|
||||
version: number;
|
||||
roots: string[];
|
||||
cpu_threads: number;
|
||||
}
|
||||
interface DesktopStatus {
|
||||
settings: Settings;
|
||||
indexing: boolean;
|
||||
shortcut_status: string;
|
||||
}
|
||||
interface SemanticStatus {
|
||||
model: string;
|
||||
ready: boolean;
|
||||
model_directory: string;
|
||||
worker_ready: boolean;
|
||||
downloading: boolean;
|
||||
embedding: boolean;
|
||||
device_policy: "auto" | "cpu";
|
||||
error: string | null;
|
||||
downloaded_bytes: number;
|
||||
total_bytes: number;
|
||||
generation: number | null;
|
||||
chunks: number;
|
||||
stale: boolean;
|
||||
pending: number;
|
||||
warming: boolean;
|
||||
worker_info?: {
|
||||
model?: string;
|
||||
device?: string;
|
||||
runtime?: string;
|
||||
threads?: number;
|
||||
} | null;
|
||||
}
|
||||
interface SearchRequest {
|
||||
id: number;
|
||||
query: string;
|
||||
filters: Record<string, string | number | null>;
|
||||
started: number;
|
||||
}
|
||||
|
||||
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
|
||||
<header><div class="wordmark">searchi<span class="dot">.</span></div><span class="privacy">● Local · CPU only</span></header>
|
||||
<main><section class="intro"><p class="eyebrow">YOUR FILES, WITHIN REACH</p><h1>Find the thing you remember.</h1><p>Search filenames and document content in your selected folders.</p></section>
|
||||
<form id="search-form" role="search"><label class="sr-only" for="query">Search your files</label><span class="search-icon" aria-hidden="true">⌕</span><input id="query" type="search" placeholder="A filename or words from a document…" autocomplete="off" spellcheck="false" maxlength="1000"><kbd>Ctrl K</kbd></form>
|
||||
<div class="toolbar"><span id="index-status">Loading your index…</span><button id="add-folder" type="button">+ Index a folder</button></div>
|
||||
<details id="filters"><summary>Filter results</summary><div class="filters-grid"><label>Within folder<input id="filter-folder" placeholder="Absolute folder path"></label><label>Extension<input id="filter-extension" placeholder="pdf, md, txt…"></label><label>Minimum size (bytes)<input id="filter-min" type="number" min="0" step="1"></label><label>Maximum size (bytes)<input id="filter-max" type="number" min="0" step="1"></label><label>Modified from<input id="filter-after" type="date"></label><label>Modified through<input id="filter-before" type="date"></label></div></details>
|
||||
<header><div class="brand"><img class="brand-symbol" src="/searchi-symbol.png" alt=""><span class="wordmark">searchi</span></div><span id="index-status" class="index-status">Loading your index…</span><div class="header-actions"><button id="add-folder" class="quiet-button" type="button">${icon("folderAdd")}<span>Add folder</span></button><details id="filters" class="popover"><summary>${icon("filter")}<span>All files</span>${icon("chevron")}</summary><div class="popover-panel"><h2>Filter your search</h2><div class="filters-grid"><label>Within folder<input id="filter-folder" placeholder="Absolute folder path"></label><label>Extension<input id="filter-extension" placeholder="pdf, md, txt…"></label><label>Minimum size (bytes)<input id="filter-min" type="number" min="0" step="1"></label><label>Maximum size (bytes)<input id="filter-max" type="number" min="0" step="1"></label><label>Modified from<input id="filter-after" type="date"></label><label>Modified through<input id="filter-before" type="date"></label></div></div></details><details id="settings" class="popover"><summary class="icon-button" aria-label="Settings" title="Settings">${icon("settings")}</summary><div class="popover-panel"><h2>Folders & resources</h2><p id="shortcut-status" class="hint"></p><label class="resource-label" for="cpu-threads">CPU thread budget</label><select id="cpu-threads">${Array.from({ length: 8 }, (_, i) => `<option value="${i + 1}">${i + 1} ${i ? "threads" : "thread (default)"}</option>`).join("")}</select><p class="hint">Search automatically uses supported acceleration with CPU fallback.</p><ul id="roots" aria-label="Selected folders"></ul></div></details></div></header>
|
||||
<main>
|
||||
<form id="search-form" role="search"><label class="sr-only" for="query">Search your files</label>${icon("search")}<input id="query" type="search" placeholder="Search a filename, phrase, or what you remember…" autocomplete="off" spellcheck="false" maxlength="1000"><kbd id="focus-shortcut">Ctrl K</kbd><button id="clear-query" class="icon-button" type="button" aria-label="Clear search" title="Clear search">${icon("dismiss")}</button></form>
|
||||
<details id="semantic-panel"><summary>${icon("sparkles")}<span id="semantic-status" role="status" aria-live="polite">Preparing local semantic search…</span><span id="semantic-device" class="kind">Local model</span>${icon("chevron")}</summary><div class="semantic-content"><strong>Google EmbeddingGemma 2</strong><progress id="semantic-progress" aria-label="Local model setup progress"></progress><p id="semantic-detail" class="hint">The verified local model downloads automatically on first launch (865 MB). Your files stay on this device.</p><div class="semantic-actions"><button id="retry-model" type="button" hidden>Retry model setup</button><button id="rebuild-semantic" type="button" hidden>Rebuild semantic index</button></div></div></details>
|
||||
<form id="folder-form" hidden><label for="folder">Folder to index</label><div class="folder-row"><input id="folder" placeholder="Absolute folder path" required><button id="browse-folder" type="button">Browse…</button><button type="submit">Index folder</button><button id="cancel-folder" type="button">Cancel</button></div><p class="hint">Only choose folders you want searched. Large folders may take a while.</p></form>
|
||||
<p id="notice" role="status" aria-live="polite"></p><div class="workspace"><section aria-label="Search results"><p id="result-summary" class="eyebrow">READY WHEN YOU ARE</p><ul id="results" aria-label="Matching files"></ul><div id="empty"><span aria-hidden="true">⌕</span><h2>Your next discovery starts here.</h2><p>Index a folder, then search a filename or a phrase from a document.</p></div></section><aside id="preview" aria-label="Result preview" hidden><p class="eyebrow">CONTENT PREVIEW</p><h2 id="preview-title"></h2><p id="preview-path" class="path"></p><p id="preview-kind"></p><div class="preview-actions"><button id="open-result" type="button">Open file</button><button id="reveal-result" type="button">Show in folder</button></div><pre id="preview-text"></pre></aside></div>
|
||||
</main><footer>Offline by default<span>↑ ↓ Navigate · Enter Preview · Esc Clear</span></footer>`;
|
||||
<p id="notice" role="status" aria-live="polite"></p><div class="workspace"><section class="results-pane" aria-label="Search results"><p id="result-summary" class="eyebrow">READY WHEN YOU ARE</p><ul id="results" aria-label="Matching files"></ul><div id="empty">${icon("search")}<h2>Find what matters. Locally.</h2><p>Index a folder, then search a filename, phrase, or what you remember.</p></div></section><aside id="preview" aria-label="Result preview" hidden><div class="preview-header"><span id="preview-icon" class="file-icon"></span><div class="preview-heading"><h2 id="preview-title"></h2><p id="preview-path" class="path"></p></div></div><div class="preview-toolbar"><span id="preview-kind"></span><div class="preview-actions"><button id="open-result" type="button">${icon("open")}<span>Open file</span></button><button id="reveal-result" type="button" title="Show in folder" aria-label="Show in folder">${icon("folder")}</button></div></div><div class="passage"><p class="eyebrow">EXTRACTED PASSAGE</p><pre id="preview-text"></pre></div></aside></div>
|
||||
</main><footer><span class="footer-privacy">${icon("lock")}<span>Private search. Everything stays on your device.</span><span id="privacy" class="privacy">Local</span></span><span class="keyboard-hints"><kbd>${icon("arrowUp")}</kbd><kbd>${icon("arrowDown")}</kbd><span>Navigate</span><kbd>Enter</kbd><span>Open</span><kbd>Esc</kbd><span>Clear</span></span></footer>`;
|
||||
|
||||
const element = <T extends HTMLElement>(id: string) =>
|
||||
document.getElementById(id) as T;
|
||||
@@ -40,6 +87,246 @@ let results: SearchResult[] = [];
|
||||
let selected = -1;
|
||||
let requestId = 0;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
let indexingActive = false;
|
||||
let semanticStatus: SemanticStatus | null = null;
|
||||
let semanticJobActive = false;
|
||||
let semanticRefreshActive = false;
|
||||
let semanticTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let hybridTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let latestSearch: SearchRequest | null = null;
|
||||
let shownLane: "lexical" | "hybrid" = "lexical";
|
||||
let folderRevision = 0;
|
||||
let localSemanticError: string | null = null;
|
||||
let querySemanticFailed = false;
|
||||
let previewPath: string | null = null;
|
||||
let displayedRequestId: number | null = null;
|
||||
let expandedError: string | null = null;
|
||||
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const resultRows = new Map<
|
||||
string,
|
||||
{
|
||||
row: HTMLLIElement;
|
||||
button: HTMLButtonElement;
|
||||
title: HTMLElement;
|
||||
path: HTMLElement;
|
||||
snippet: HTMLElement;
|
||||
kind: HTMLElement;
|
||||
file: HTMLElement;
|
||||
}
|
||||
>();
|
||||
const automaticIndex = new AutomaticIndexGate();
|
||||
|
||||
function refreshEmptySearchState() {
|
||||
if (!latestSearch || latestSearch.id !== requestId || results.length) return;
|
||||
let phase: SemanticPhase;
|
||||
if (querySemanticFailed || localSemanticError || semanticStatus?.error)
|
||||
phase = "unavailable";
|
||||
else if (
|
||||
semanticStatus?.embedding ||
|
||||
semanticJobActive ||
|
||||
indexingActive ||
|
||||
(semanticStatus?.ready &&
|
||||
semanticStatus.stale &&
|
||||
semanticStatus.pending > 0)
|
||||
)
|
||||
phase = "indexing";
|
||||
else if (!semanticStatus?.ready) phase = "preparing";
|
||||
else if (semanticStatus.generation === null) phase = "no-content";
|
||||
else phase = shownLane === "hybrid" ? "complete" : "pending";
|
||||
const state = emptySearchState(shownLane, phase);
|
||||
element("empty").querySelector("h2")!.textContent = state.title;
|
||||
element("empty").querySelector("p")!.textContent = state.detail;
|
||||
}
|
||||
|
||||
function canSearchSemantically() {
|
||||
return Boolean(
|
||||
semanticStatus?.ready &&
|
||||
semanticStatus.generation !== null &&
|
||||
!semanticStatus.embedding &&
|
||||
!semanticJobActive,
|
||||
);
|
||||
}
|
||||
|
||||
function queueSemanticRefresh(delay = 1000) {
|
||||
clearTimeout(semanticTimer);
|
||||
semanticTimer = setTimeout(() => {
|
||||
void refreshSemanticStatus();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
async function buildSemanticIndex() {
|
||||
if (semanticJobActive || indexingActive) return;
|
||||
semanticJobActive = true;
|
||||
localSemanticError = null;
|
||||
element("semantic-status").textContent =
|
||||
"Building your semantic index… Keyword search remains available.";
|
||||
element<HTMLProgressElement>("semantic-progress").removeAttribute("value");
|
||||
element("semantic-progress").hidden = false;
|
||||
queueSemanticRefresh();
|
||||
try {
|
||||
const report = await invoke<{ passages: number; chunks: number }>(
|
||||
"index_embeddings",
|
||||
);
|
||||
element("semantic-status").textContent =
|
||||
`Semantic search indexed ${report.chunks.toLocaleString()} passages.`;
|
||||
} catch (error) {
|
||||
localSemanticError = String(error);
|
||||
} finally {
|
||||
semanticJobActive = false;
|
||||
queueSemanticRefresh(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSemanticStatus() {
|
||||
if (semanticRefreshActive) {
|
||||
queueSemanticRefresh();
|
||||
return;
|
||||
}
|
||||
semanticRefreshActive = true;
|
||||
try {
|
||||
const previousReady = canSearchSemantically();
|
||||
const previousGeneration = semanticStatus?.generation;
|
||||
const status = await invoke<SemanticStatus>("semantic_status");
|
||||
semanticStatus = status;
|
||||
refreshEmptySearchState();
|
||||
const error = localSemanticError || status.error;
|
||||
if (error && error !== expandedError) {
|
||||
element<HTMLDetailsElement>("semantic-panel").open = true;
|
||||
expandedError = error;
|
||||
}
|
||||
if (!error) expandedError = null;
|
||||
const progress = element<HTMLProgressElement>("semantic-progress");
|
||||
const working =
|
||||
status.downloading ||
|
||||
status.warming ||
|
||||
status.embedding ||
|
||||
semanticJobActive;
|
||||
progress.hidden = !working;
|
||||
progress.removeAttribute("value");
|
||||
if (
|
||||
status.downloading &&
|
||||
status.total_bytes > 0 &&
|
||||
status.downloaded_bytes < status.total_bytes
|
||||
) {
|
||||
progress.max = status.total_bytes;
|
||||
progress.value = status.downloaded_bytes;
|
||||
}
|
||||
const actualDevice =
|
||||
status.worker_info?.device ||
|
||||
(status.device_policy === "cpu" ? "CPU" : "Automatic acceleration");
|
||||
element("privacy").textContent = `Local · ${actualDevice}`;
|
||||
element("semantic-device").textContent = actualDevice;
|
||||
let message: string;
|
||||
if (error) message = error;
|
||||
else if (status.warming)
|
||||
message = "Preparing Google semantic search on your device…";
|
||||
else if (status.downloading)
|
||||
message =
|
||||
status.downloaded_bytes >= status.total_bytes
|
||||
? "Verifying the downloaded Google model…"
|
||||
: `Downloading Google model · ${Math.round(status.downloaded_bytes / 1_000_000)} of ${Math.round(status.total_bytes / 1_000_000)} MB`;
|
||||
else if (status.embedding || semanticJobActive)
|
||||
message =
|
||||
"Building your semantic index… Keyword search remains available.";
|
||||
else if (status.ready && status.chunks > 0)
|
||||
message = `Semantic search ready · ${status.chunks.toLocaleString()} passages${status.stale ? " · Updates pending" : ""}`;
|
||||
else if (status.ready)
|
||||
message = "Google model ready. Index a folder to search by meaning.";
|
||||
else
|
||||
message =
|
||||
"Preparing local semantic search… Keyword search remains available.";
|
||||
element("semantic-status").textContent = message;
|
||||
element("semantic-status").classList.toggle("error", Boolean(error));
|
||||
element("semantic-detail").textContent = status.worker_info
|
||||
? `${status.model} · ${status.worker_info.runtime || "Local inference"} · Your files stay on this device.`
|
||||
: "The verified Google model downloads automatically once (865 MB). After setup, inference runs locally.";
|
||||
const retry = element<HTMLButtonElement>("retry-model");
|
||||
retry.hidden = !error || status.ready;
|
||||
retry.disabled = working;
|
||||
const rebuild = element<HTMLButtonElement>("rebuild-semantic");
|
||||
rebuild.hidden = !status.ready || (!status.chunks && !status.pending);
|
||||
rebuild.disabled = working || indexingActive;
|
||||
element<HTMLSelectElement>("cpu-threads").disabled =
|
||||
working || indexingActive;
|
||||
if (
|
||||
status.ready &&
|
||||
status.stale &&
|
||||
status.pending > 0 &&
|
||||
!working &&
|
||||
!indexingActive
|
||||
) {
|
||||
const key = `${status.generation}:${status.pending}:${folderRevision}`;
|
||||
if (automaticIndex.claim(key)) {
|
||||
void buildSemanticIndex();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (
|
||||
(!previousReady || previousGeneration !== status.generation) &&
|
||||
canSearchSemantically() &&
|
||||
latestSearch?.id === requestId
|
||||
)
|
||||
hybridLane.submit(latestSearch);
|
||||
if (working || (!status.ready && !error)) queueSemanticRefresh();
|
||||
} catch (error) {
|
||||
element("semantic-status").textContent =
|
||||
`Semantic search unavailable: ${String(error)}`;
|
||||
element("semantic-status").classList.add("error");
|
||||
element<HTMLButtonElement>("retry-model").hidden = false;
|
||||
} finally {
|
||||
semanticRefreshActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDesktopStatus() {
|
||||
const status = await invoke<DesktopStatus>("desktop_status");
|
||||
indexingActive = status.indexing;
|
||||
element("shortcut-status").textContent = status.shortcut_status;
|
||||
element("focus-shortcut").textContent = status.shortcut_status.includes(
|
||||
"Super",
|
||||
)
|
||||
? "Cmd K"
|
||||
: "Ctrl K";
|
||||
const threads = element<HTMLSelectElement>("cpu-threads");
|
||||
threads.value = String(status.settings.cpu_threads);
|
||||
threads.disabled =
|
||||
status.indexing ||
|
||||
semanticJobActive ||
|
||||
Boolean(
|
||||
semanticStatus?.downloading ||
|
||||
semanticStatus?.warming ||
|
||||
semanticStatus?.embedding,
|
||||
);
|
||||
element<HTMLButtonElement>("add-folder").disabled = status.indexing;
|
||||
const roots = element("roots");
|
||||
roots.replaceChildren();
|
||||
for (const root of status.settings.roots) {
|
||||
const item = document.createElement("li");
|
||||
const path = document.createElement("span");
|
||||
path.textContent = root;
|
||||
path.className = "path";
|
||||
const reindex = document.createElement("button");
|
||||
reindex.type = "button";
|
||||
reindex.textContent = "Reindex";
|
||||
reindex.disabled = status.indexing;
|
||||
reindex.addEventListener("click", () => {
|
||||
element("folder-form").hidden = false;
|
||||
element<HTMLInputElement>("folder").value = root;
|
||||
element("folder").focus();
|
||||
});
|
||||
item.append(path, reindex);
|
||||
roots.append(item);
|
||||
}
|
||||
if (!status.settings.roots.length) {
|
||||
const item = document.createElement("li");
|
||||
item.textContent = "No folders selected yet.";
|
||||
roots.append(item);
|
||||
}
|
||||
if (status.indexing)
|
||||
element("index-status").textContent =
|
||||
"Indexing active · Search remains available";
|
||||
return status;
|
||||
}
|
||||
|
||||
function showError(error: unknown) {
|
||||
notice.textContent = String(error);
|
||||
@@ -51,10 +338,14 @@ function select(index: number, focus = false) {
|
||||
const result = results[selected];
|
||||
list.querySelectorAll<HTMLButtonElement>("button").forEach((button, i) => {
|
||||
button.classList.toggle("selected", i === selected);
|
||||
button.setAttribute("aria-pressed", String(i === selected));
|
||||
button.setAttribute("aria-current", String(i === selected));
|
||||
button.tabIndex = i === selected ? 0 : -1;
|
||||
if (i === selected && focus) button.focus();
|
||||
});
|
||||
element("preview").hidden = false;
|
||||
const selectedIcon = fileIcon(result.filename);
|
||||
element("preview-icon").innerHTML = selectedIcon.html;
|
||||
element("preview-icon").dataset.type = selectedIcon.type;
|
||||
element("preview-title").textContent = result.filename;
|
||||
element("preview-path").textContent = result.path;
|
||||
element("preview-kind").textContent =
|
||||
@@ -64,102 +355,218 @@ function select(index: number, focus = false) {
|
||||
(result.extraction_error
|
||||
? `Content could not be extracted: ${result.extraction_error}`
|
||||
: "No extracted text is available for this file.");
|
||||
element<HTMLButtonElement>("open-result").title = result.source
|
||||
? `Opens this file in your default application. ${result.source.kind} ${result.source.number} is shown here; exact-location opening is not supported by the default application interface.`
|
||||
: "Open in your default application";
|
||||
if (previewPath !== result.path) {
|
||||
const passage = document.querySelector<HTMLElement>(".passage")!;
|
||||
passage.scrollTop = 0;
|
||||
if (!reducedMotion.matches) {
|
||||
passage.getAnimations().forEach((animation) => animation.cancel());
|
||||
passage.animate(
|
||||
[
|
||||
{ opacity: 0.65, transform: "translateY(3px)" },
|
||||
{ opacity: 1, transform: "translateY(0)" },
|
||||
],
|
||||
{ duration: 150, easing: "ease-out" },
|
||||
);
|
||||
}
|
||||
previewPath = result.path;
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
list.replaceChildren();
|
||||
const scrollTop = list.scrollTop;
|
||||
const retained = new Set(results.map((result) => result.path));
|
||||
for (const [key, entry] of resultRows) {
|
||||
if (!retained.has(key)) {
|
||||
entry.row.remove();
|
||||
resultRows.delete(key);
|
||||
}
|
||||
}
|
||||
element("empty").hidden = results.length > 0;
|
||||
element("preview").hidden = true;
|
||||
if (!results.length) {
|
||||
element("preview").hidden = true;
|
||||
previewPath = null;
|
||||
}
|
||||
results.forEach((result, i) => {
|
||||
const row = document.createElement("li");
|
||||
const button = document.createElement("button");
|
||||
button.className = "result";
|
||||
button.type = "button";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = result.filename;
|
||||
const path = document.createElement("span");
|
||||
path.className = "path";
|
||||
path.textContent = result.path;
|
||||
const snippet = document.createElement("span");
|
||||
snippet.className = "snippet";
|
||||
snippet.textContent = result.snippet;
|
||||
const kind = document.createElement("span");
|
||||
kind.className = "kind";
|
||||
kind.textContent = result.match_kind;
|
||||
button.append(title, kind, path, snippet);
|
||||
button.addEventListener("click", () => select(i));
|
||||
row.append(button);
|
||||
list.append(row);
|
||||
let entry = resultRows.get(result.path);
|
||||
if (!entry) {
|
||||
const row = document.createElement("li");
|
||||
if (i < 8) row.className = "new-row";
|
||||
const button = document.createElement("button");
|
||||
button.className = "result";
|
||||
button.type = "button";
|
||||
const file = document.createElement("span");
|
||||
file.className = "file-icon";
|
||||
const title = document.createElement("strong");
|
||||
const path = document.createElement("span");
|
||||
path.className = "path";
|
||||
const snippet = document.createElement("span");
|
||||
snippet.className = "snippet";
|
||||
const kind = document.createElement("span");
|
||||
kind.className = "kind";
|
||||
button.append(file, title, kind, snippet, path);
|
||||
button.addEventListener("click", () =>
|
||||
select(Number(button.dataset.index)),
|
||||
);
|
||||
row.append(button);
|
||||
entry = { row, button, file, title, path, snippet, kind };
|
||||
resultRows.set(result.path, entry);
|
||||
}
|
||||
entry.button.dataset.index = String(i);
|
||||
entry.button.title = result.path;
|
||||
entry.title.textContent = result.filename;
|
||||
entry.path.textContent = result.path;
|
||||
entry.snippet.textContent = result.snippet;
|
||||
entry.kind.textContent = result.source
|
||||
? `${result.source.kind} ${result.source.number}`
|
||||
: result.match_kind;
|
||||
const asset = fileIcon(result.filename);
|
||||
if (entry.file.dataset.type !== asset.type) {
|
||||
entry.file.innerHTML = asset.html;
|
||||
entry.file.dataset.type = asset.type;
|
||||
}
|
||||
const positioned = list.children.item(i);
|
||||
if (positioned !== entry.row) list.insertBefore(entry.row, positioned);
|
||||
});
|
||||
if (results.length) select(0);
|
||||
list.scrollTop = scrollTop;
|
||||
}
|
||||
|
||||
function captureFilters() {
|
||||
const input = (id: string) => element<HTMLInputElement>(id).value.trim();
|
||||
const size = (id: string) => {
|
||||
const text = input(id);
|
||||
if (!text) return null;
|
||||
const value = Number(text);
|
||||
if (!Number.isSafeInteger(value) || value < 0)
|
||||
throw new Error("File size must be a nonnegative whole number of bytes.");
|
||||
return value;
|
||||
};
|
||||
// Date controls represent the user's local calendar day, including its end.
|
||||
const date = (id: string, end: boolean) => {
|
||||
const text = input(id);
|
||||
if (!text) return null;
|
||||
const stamp = new Date(
|
||||
`${text}T${end ? "23:59:59.999" : "00:00:00"}`,
|
||||
).getTime();
|
||||
if (!Number.isFinite(stamp))
|
||||
throw new Error("Enter a valid modification date.");
|
||||
return stamp;
|
||||
};
|
||||
return {
|
||||
folder: input("filter-folder") || null,
|
||||
extension: input("filter-extension") || null,
|
||||
min_size: size("filter-min"),
|
||||
max_size: size("filter-max"),
|
||||
modified_after_unix_ms: date("filter-after", false),
|
||||
modified_before_unix_ms: date("filter-before", true),
|
||||
};
|
||||
}
|
||||
|
||||
function applyResults(
|
||||
found: SearchResult[],
|
||||
request: SearchRequest,
|
||||
lane: "lexical" | "hybrid",
|
||||
) {
|
||||
if (
|
||||
request.id !== requestId ||
|
||||
(lane === "lexical" && shownLane === "hybrid")
|
||||
)
|
||||
return;
|
||||
const selectedPath = results[selected]?.path;
|
||||
const resultsHadFocus = list.contains(document.activeElement);
|
||||
shownLane = lane;
|
||||
displayedRequestId = request.id;
|
||||
results = found;
|
||||
selected = -1;
|
||||
render();
|
||||
const preserved = results.findIndex((result) => result.path === selectedPath);
|
||||
if (preserved >= 0) select(preserved, resultsHadFocus);
|
||||
else if (results.length) select(0, resultsHadFocus);
|
||||
element("result-summary").textContent =
|
||||
`${found.length} RESULTS · ${lane === "hybrid" ? "KEYWORD + MEANING" : "KEYWORD"} · ${Math.round(performance.now() - request.started)} MS`;
|
||||
if (!found.length) refreshEmptySearchState();
|
||||
}
|
||||
|
||||
const isCurrent = (request: SearchRequest) => request.id === requestId;
|
||||
const lexicalLane = new LatestLane<SearchRequest>(
|
||||
async (request) => {
|
||||
const found = await invoke<SearchResult[]>("search", {
|
||||
query: request.query,
|
||||
filters: request.filters,
|
||||
});
|
||||
applyResults(found, request, "lexical");
|
||||
},
|
||||
isCurrent,
|
||||
(_request, error) => {
|
||||
if (shownLane !== "hybrid") {
|
||||
showError(error);
|
||||
element("result-summary").textContent = "KEYWORD SEARCH UNAVAILABLE";
|
||||
}
|
||||
},
|
||||
);
|
||||
const hybridLane = new LatestLane<SearchRequest>(
|
||||
async (request) => {
|
||||
if (!canSearchSemantically()) return;
|
||||
const found = await invoke<SearchResult[]>("hybrid_search", {
|
||||
query: request.query,
|
||||
filters: request.filters,
|
||||
});
|
||||
applyResults(found, request, "hybrid");
|
||||
},
|
||||
isCurrent,
|
||||
(_request, error) => {
|
||||
querySemanticFailed = true;
|
||||
refreshEmptySearchState();
|
||||
element("semantic-status").textContent =
|
||||
`Semantic results unavailable: ${String(error)}. Keyword results remain available.`;
|
||||
queueSemanticRefresh();
|
||||
},
|
||||
);
|
||||
|
||||
function invalidateSearch() {
|
||||
++requestId;
|
||||
clearTimeout(timer);
|
||||
clearTimeout(hybridTimer);
|
||||
lexicalLane.clear();
|
||||
hybridLane.clear();
|
||||
latestSearch = null;
|
||||
}
|
||||
|
||||
async function search() {
|
||||
const id = ++requestId;
|
||||
invalidateSearch();
|
||||
querySemanticFailed = false;
|
||||
shownLane = "lexical";
|
||||
const value = query.value.trim();
|
||||
if (!value) {
|
||||
results = [];
|
||||
displayedRequestId = null;
|
||||
selected = -1;
|
||||
render();
|
||||
element("result-summary").textContent = "READY WHEN YOU ARE";
|
||||
element("empty").querySelector("h2")!.textContent =
|
||||
"Your next discovery starts here.";
|
||||
"Find what matters. Locally.";
|
||||
element("empty").querySelector("p")!.textContent =
|
||||
"Index a folder, then search a filename or a phrase from a document.";
|
||||
"Index a folder, then search a filename, phrase, or what you remember.";
|
||||
return;
|
||||
}
|
||||
element("result-summary").textContent = "SEARCHING…";
|
||||
const started = performance.now();
|
||||
try {
|
||||
const input = (id: string) => element<HTMLInputElement>(id).value.trim();
|
||||
const size = (id: string) => {
|
||||
const text = input(id);
|
||||
if (!text) return null;
|
||||
const value = Number(text);
|
||||
if (!Number.isSafeInteger(value) || value < 0)
|
||||
throw new Error(
|
||||
"File size must be a nonnegative whole number of bytes.",
|
||||
);
|
||||
return value;
|
||||
};
|
||||
// Date controls represent the user's local calendar day, including its end.
|
||||
const date = (id: string, end: boolean) => {
|
||||
const text = input(id);
|
||||
if (!text) return null;
|
||||
const stamp = new Date(
|
||||
`${text}T${end ? "23:59:59.999" : "00:00:00"}`,
|
||||
).getTime();
|
||||
if (!Number.isFinite(stamp))
|
||||
throw new Error("Enter a valid modification date.");
|
||||
return stamp;
|
||||
};
|
||||
const found = await invoke<SearchResult[]>("search", {
|
||||
const request: SearchRequest = {
|
||||
id: requestId,
|
||||
query: value,
|
||||
filters: {
|
||||
folder: input("filter-folder") || null,
|
||||
extension: input("filter-extension") || null,
|
||||
min_size: size("filter-min"),
|
||||
max_size: size("filter-max"),
|
||||
modified_after_unix_ms: date("filter-after", false),
|
||||
modified_before_unix_ms: date("filter-before", true),
|
||||
},
|
||||
});
|
||||
if (id !== requestId) return;
|
||||
results = found;
|
||||
selected = -1;
|
||||
render();
|
||||
element("result-summary").textContent =
|
||||
`${found.length} RESULTS · ${Math.round(performance.now() - started)} MS`;
|
||||
if (!found.length) {
|
||||
element("empty").querySelector("h2")!.textContent =
|
||||
"No matching files yet.";
|
||||
element("empty").querySelector("p")!.textContent =
|
||||
"Try a shorter phrase, an exact filename, or index another folder.";
|
||||
}
|
||||
filters: captureFilters(),
|
||||
started: performance.now(),
|
||||
};
|
||||
latestSearch = request;
|
||||
element("result-summary").textContent = "SEARCHING…";
|
||||
lexicalLane.submit(request);
|
||||
hybridTimer = setTimeout(() => {
|
||||
if (isCurrent(request) && canSearchSemantically())
|
||||
hybridLane.submit(request);
|
||||
}, 350);
|
||||
} catch (error) {
|
||||
if (id === requestId) {
|
||||
showError(error);
|
||||
element("result-summary").textContent = "SEARCH FAILED";
|
||||
}
|
||||
showError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,15 +577,36 @@ async function refreshStats() {
|
||||
}
|
||||
|
||||
query.addEventListener("input", () => {
|
||||
++requestId;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(search, 100);
|
||||
invalidateSearch();
|
||||
timer = setTimeout(search, 60);
|
||||
});
|
||||
element("search-form").addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
clearTimeout(timer);
|
||||
void search();
|
||||
});
|
||||
element("clear-query").addEventListener("click", () => {
|
||||
query.value = "";
|
||||
void search();
|
||||
query.focus();
|
||||
});
|
||||
document.querySelectorAll<HTMLDetailsElement>(".popover").forEach((popover) => {
|
||||
popover.addEventListener("toggle", () => {
|
||||
if (popover.open)
|
||||
document
|
||||
.querySelectorAll<HTMLDetailsElement>(".popover")
|
||||
.forEach((other) => {
|
||||
if (other !== popover) other.open = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
document.addEventListener("pointerdown", (event) => {
|
||||
document
|
||||
.querySelectorAll<HTMLDetailsElement>(".popover[open]")
|
||||
.forEach((popover) => {
|
||||
if (!popover.contains(event.target as Node)) popover.open = false;
|
||||
});
|
||||
});
|
||||
element("add-folder").addEventListener("click", () => {
|
||||
element("folder-form").hidden = false;
|
||||
element("folder").focus();
|
||||
@@ -196,10 +624,26 @@ element("browse-folder").addEventListener("click", async () => {
|
||||
}
|
||||
});
|
||||
element("filters").addEventListener("change", () => {
|
||||
++requestId;
|
||||
clearTimeout(timer);
|
||||
void search();
|
||||
});
|
||||
element("retry-model").addEventListener("click", async () => {
|
||||
const button = element<HTMLButtonElement>("retry-model");
|
||||
button.disabled = true;
|
||||
localSemanticError = null;
|
||||
element("semantic-status").textContent = "Retrying Google model setup…";
|
||||
queueSemanticRefresh();
|
||||
try {
|
||||
await invoke("setup_model");
|
||||
} catch (error) {
|
||||
localSemanticError = String(error);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
queueSemanticRefresh(0);
|
||||
}
|
||||
});
|
||||
element("rebuild-semantic").addEventListener("click", () => {
|
||||
void buildSemanticIndex();
|
||||
});
|
||||
async function openSelected(reveal: boolean) {
|
||||
if (selected < 0 || !results[selected]) return;
|
||||
try {
|
||||
@@ -214,6 +658,18 @@ element("open-result").addEventListener("click", () => {
|
||||
element("reveal-result").addEventListener("click", () => {
|
||||
void openSelected(true);
|
||||
});
|
||||
element("cpu-threads").addEventListener("change", async () => {
|
||||
try {
|
||||
await invoke<Settings>("set_cpu_threads", {
|
||||
threads: Number(element<HTMLSelectElement>("cpu-threads").value),
|
||||
});
|
||||
await refreshDesktopStatus();
|
||||
queueSemanticRefresh(0);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
await refreshDesktopStatus().catch(showError);
|
||||
}
|
||||
});
|
||||
element("folder-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const input = element<HTMLInputElement>("folder");
|
||||
@@ -223,6 +679,10 @@ element("folder-form").addEventListener("submit", async (event) => {
|
||||
button.disabled = true;
|
||||
});
|
||||
element<HTMLButtonElement>("add-folder").disabled = true;
|
||||
element<HTMLSelectElement>("cpu-threads").disabled = true;
|
||||
indexingActive = true;
|
||||
element("index-status").textContent =
|
||||
"Indexing active · Search remains available";
|
||||
notice.className = "";
|
||||
notice.textContent = "Indexing folder… Search remains available.";
|
||||
try {
|
||||
@@ -231,6 +691,8 @@ element("folder-form").addEventListener("submit", async (event) => {
|
||||
});
|
||||
notice.textContent = `Indexed ${report.indexed} files. ${report.removed} removed; ${report.skipped} skipped.`;
|
||||
element("folder-form").hidden = true;
|
||||
folderRevision++;
|
||||
localSemanticError = null;
|
||||
await refreshStats();
|
||||
await search();
|
||||
} catch (error) {
|
||||
@@ -240,6 +702,9 @@ element("folder-form").addEventListener("submit", async (event) => {
|
||||
button.disabled = false;
|
||||
});
|
||||
element<HTMLButtonElement>("add-folder").disabled = false;
|
||||
await refreshDesktopStatus().catch(showError);
|
||||
await refreshStats().catch(showError);
|
||||
queueSemanticRefresh(0);
|
||||
}
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
@@ -249,12 +714,25 @@ document.addEventListener("keydown", (event) => {
|
||||
query.select();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
document
|
||||
.querySelectorAll<HTMLDetailsElement>(".popover[open]")
|
||||
.forEach((popover) => {
|
||||
popover.open = false;
|
||||
});
|
||||
++requestId;
|
||||
query.value = "";
|
||||
void search();
|
||||
query.focus();
|
||||
}
|
||||
if (event.target === query || list.contains(event.target as Node)) {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
results[selected] &&
|
||||
displayedRequestId === requestId
|
||||
) {
|
||||
event.preventDefault();
|
||||
void openSelected(false);
|
||||
}
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
select(selected + (event.key === "ArrowDown" ? 1 : -1), true);
|
||||
@@ -263,8 +741,25 @@ document.addEventListener("keydown", (event) => {
|
||||
});
|
||||
if (isTauri()) {
|
||||
void refreshStats().catch(showError);
|
||||
void refreshDesktopStatus().catch(showError);
|
||||
void refreshSemanticStatus();
|
||||
void listen("focus-search", () => {
|
||||
query.focus();
|
||||
query.select();
|
||||
void refreshDesktopStatus().catch(showError);
|
||||
queueSemanticRefresh(0);
|
||||
}).catch(showError);
|
||||
// Poll only during a user-triggered job; idle indexing status does no work.
|
||||
setInterval(() => {
|
||||
if (indexingActive) void refreshDesktopStatus().catch(showError);
|
||||
}, 2000);
|
||||
query.focus();
|
||||
} else {
|
||||
element("index-status").textContent = "Desktop preview";
|
||||
element("semantic-status").textContent =
|
||||
"Native semantic search runs in the desktop app.";
|
||||
element("semantic-progress").hidden = true;
|
||||
element("semantic-detail").textContent =
|
||||
"This browser preview does not download models or return simulated search results.";
|
||||
showError("Run the Tauri desktop app to index and search real files.");
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/** One active request per lane; intermediate keystrokes collapse to the latest.
|
||||
* Native work already running cannot be cancelled, so revisions guard every
|
||||
* result and retry. Busy responses receive at most one delayed retry. */
|
||||
export class LatestLane<T> {
|
||||
private pending: T | undefined;
|
||||
private running = false;
|
||||
private run: (value: T) => Promise<void>;
|
||||
private current: (value: T) => boolean;
|
||||
private fail: (value: T, error: unknown) => void;
|
||||
private delay: () => Promise<void>;
|
||||
|
||||
constructor(
|
||||
run: (value: T) => Promise<void>,
|
||||
current: (value: T) => boolean,
|
||||
fail: (value: T, error: unknown) => void,
|
||||
delay = () => new Promise<void>((resolve) => setTimeout(resolve, 400)),
|
||||
) {
|
||||
this.run = run;
|
||||
this.current = current;
|
||||
this.fail = fail;
|
||||
this.delay = delay;
|
||||
}
|
||||
|
||||
submit(value: T) {
|
||||
this.pending = value;
|
||||
if (!this.running) void this.drain();
|
||||
}
|
||||
clear() {
|
||||
this.pending = undefined;
|
||||
}
|
||||
|
||||
private async drain() {
|
||||
this.running = true;
|
||||
try {
|
||||
while (this.pending !== undefined) {
|
||||
const value = this.pending;
|
||||
this.pending = undefined;
|
||||
if (!this.current(value)) continue;
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
await this.run(value);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (!this.current(value)) break;
|
||||
if (
|
||||
attempt === 0 &&
|
||||
/busy|in progress|currently running|already running/i.test(
|
||||
String(error),
|
||||
)
|
||||
) {
|
||||
await this.delay();
|
||||
if (!this.current(value) || this.pending !== undefined) break;
|
||||
} else {
|
||||
this.fail(value, error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** An unsuccessful automatic build is not retried on every status poll. */
|
||||
export class AutomaticIndexGate {
|
||||
private attempted = new Set<string>();
|
||||
claim(key: string) {
|
||||
if (this.attempted.has(key)) return false;
|
||||
this.attempted.add(key);
|
||||
// Bound memory even across a very long desktop session.
|
||||
if (this.attempted.size > 128)
|
||||
this.attempted.delete(this.attempted.values().next().value!);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export type SemanticPhase =
|
||||
| "preparing"
|
||||
| "indexing"
|
||||
| "pending"
|
||||
| "unavailable"
|
||||
| "no-content"
|
||||
| "complete";
|
||||
|
||||
/** Zero keyword matches are provisional until the semantic lane completes. */
|
||||
export function emptySearchState(
|
||||
lane: "lexical" | "hybrid",
|
||||
phase: SemanticPhase,
|
||||
) {
|
||||
const title =
|
||||
lane === "lexical"
|
||||
? "No keyword matches yet."
|
||||
: "No matches in the current index yet.";
|
||||
if (phase === "preparing")
|
||||
return {
|
||||
title:
|
||||
lane === "lexical" ? title : "No matches in the current index yet.",
|
||||
detail:
|
||||
"Google semantic search is still preparing. This query will also search by meaning when it is ready.",
|
||||
};
|
||||
if (phase === "indexing")
|
||||
return {
|
||||
title:
|
||||
lane === "lexical" ? title : "No matches in the current index yet.",
|
||||
detail:
|
||||
"Semantic indexing is underway. Results may change when your documents finish indexing.",
|
||||
};
|
||||
if (phase === "pending")
|
||||
return {
|
||||
title,
|
||||
detail:
|
||||
"Searching by meaning… Semantic results will appear here when this query finishes.",
|
||||
};
|
||||
if (phase === "unavailable")
|
||||
return {
|
||||
title,
|
||||
detail:
|
||||
"Semantic results are currently unavailable; see the model status above. Try a filename or words from your document, or retry the search.",
|
||||
};
|
||||
if (phase === "no-content")
|
||||
return {
|
||||
title,
|
||||
detail:
|
||||
"No document content is indexed for semantic search yet. Index a folder with readable documents, or search a filename.",
|
||||
};
|
||||
return {
|
||||
title: lane === "hybrid" ? "No matches for this search." : title,
|
||||
detail:
|
||||
"Try another phrase, broaden your filters, or index another folder.",
|
||||
};
|
||||
}
|
||||
+639
-214
@@ -1,13 +1,38 @@
|
||||
:root {
|
||||
font-family:
|
||||
Inter,
|
||||
Segoe UI,
|
||||
"Inter Variable",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
color: #20332e;
|
||||
background: #f6f7f2;
|
||||
font-synthesis: none;
|
||||
color-scheme: light;
|
||||
color-scheme: light dark;
|
||||
--brand: #2563ff;
|
||||
--canvas: #f8fafc;
|
||||
--surface: #fff;
|
||||
--subtle: #f1f5f9;
|
||||
--selected: #e8efff;
|
||||
--ink: #0b0f14;
|
||||
--muted: #64748b;
|
||||
--line: #e2e8f0;
|
||||
--error: #b42318;
|
||||
--shadow: 0 12px 36px #0b0f1418;
|
||||
color: var(--ink);
|
||||
background: var(--canvas);
|
||||
font-size: 14px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--canvas: #0b0f14;
|
||||
--surface: #111720;
|
||||
--subtle: #18212e;
|
||||
--selected: #182c53;
|
||||
--ink: #f8fafc;
|
||||
--muted: #9cacc2;
|
||||
--line: #263141;
|
||||
--error: #ffaaa2;
|
||||
--shadow: 0 12px 36px #00000050;
|
||||
}
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@@ -15,250 +40,626 @@
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
#app {
|
||||
height: 100dvh;
|
||||
min-height: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
button {
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
button,
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.6;
|
||||
}
|
||||
header,
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 24px 40px;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
padding: 8px 12px;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
border-color 120ms ease;
|
||||
}
|
||||
button:hover,
|
||||
summary:hover {
|
||||
background: var(--subtle);
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
input,
|
||||
select {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 9px 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
input::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
input[type="search"]::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
summary:focus-visible {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: currentColor;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 16px 22px 12px;
|
||||
min-height: 64px;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.brand-symbol {
|
||||
width: 29px;
|
||||
height: 29px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.wordmark {
|
||||
font-size: 28px;
|
||||
font-size: 20px;
|
||||
font-weight: 750;
|
||||
letter-spacing: -1.5px;
|
||||
letter-spacing: -0.65px;
|
||||
}
|
||||
.dot {
|
||||
color: #87a77f;
|
||||
}
|
||||
.privacy {
|
||||
.index-status {
|
||||
font-size: 12px;
|
||||
color: #58705c;
|
||||
color: var(--muted);
|
||||
border-left: 1px solid var(--line);
|
||||
padding-left: 18px;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.quiet-button {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
}
|
||||
.icon-button {
|
||||
padding: 7px;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
main {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 40px;
|
||||
}
|
||||
.intro {
|
||||
margin: 12px 0 30px;
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.8px;
|
||||
color: #78887c;
|
||||
}
|
||||
.intro h1 {
|
||||
font-size: clamp(28px, 4vw, 42px);
|
||||
letter-spacing: -1.5px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.intro > p:last-child {
|
||||
color: #738077;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 18px;
|
||||
}
|
||||
#search-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: white;
|
||||
border: 1px solid #d2dccf;
|
||||
border-radius: 14px;
|
||||
padding: 18px 22px;
|
||||
box-shadow: 0 8px 30px #243b2910;
|
||||
}
|
||||
#query {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 19px;
|
||||
color: inherit;
|
||||
outline: none;
|
||||
gap: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 12px 15px;
|
||||
min-height: 56px;
|
||||
box-shadow: 0 2px 8px #0b0f1404;
|
||||
}
|
||||
#search-form:focus-within {
|
||||
border-color: #588663;
|
||||
box-shadow: 0 0 0 3px #65866922;
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 2px #2563ff15;
|
||||
}
|
||||
.search-icon {
|
||||
font-size: 30px;
|
||||
color: #6a8770;
|
||||
#search-form > .icon {
|
||||
color: var(--muted);
|
||||
}
|
||||
#search-form > .icon svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
#query {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
font-size: 18px;
|
||||
background: transparent;
|
||||
letter-spacing: -0.25px;
|
||||
}
|
||||
#query:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
kbd {
|
||||
white-space: nowrap;
|
||||
color: #829086;
|
||||
font-size: 11px;
|
||||
border: 1px solid #dce3d9;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 18px 0;
|
||||
font-size: 12px;
|
||||
color: #78847b;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
padding: 4px 6px;
|
||||
min-height: 23px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.toolbar button,
|
||||
.folder-row button {
|
||||
background: #e8ede2;
|
||||
border: 1px solid #d9e0d2;
|
||||
padding: 9px 14px;
|
||||
kbd .icon svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
.popover {
|
||||
position: relative;
|
||||
}
|
||||
summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
}
|
||||
summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.popover > summary {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
color: #36583c;
|
||||
padding: 8px 10px;
|
||||
background: var(--surface);
|
||||
font-size: 12px;
|
||||
}
|
||||
#settings > summary {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 8px;
|
||||
}
|
||||
.popover-panel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 9px);
|
||||
width: 360px;
|
||||
max-width: calc(100vw - 36px);
|
||||
z-index: 20;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
max-height: calc(100vh - 120px);
|
||||
overflow: auto;
|
||||
animation: panel-in 160ms ease-out;
|
||||
}
|
||||
.popover-panel h2 {
|
||||
font-size: 15px;
|
||||
margin: 0 0 15px;
|
||||
}
|
||||
.filters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 13px;
|
||||
}
|
||||
.filters-grid label {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.filters-grid input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.resource-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
#cpu-threads {
|
||||
width: 100%;
|
||||
}
|
||||
#roots {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
#roots li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 9px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
#roots button {
|
||||
font-size: 11px;
|
||||
padding: 6px 9px;
|
||||
}
|
||||
#semantic-panel {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
#semantic-panel > summary {
|
||||
min-height: 34px;
|
||||
padding: 5px 7px;
|
||||
border-radius: 6px;
|
||||
color: var(--muted);
|
||||
}
|
||||
#semantic-panel > summary > .icon:first-child {
|
||||
color: var(--brand);
|
||||
}
|
||||
#semantic-status {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.semantic-content {
|
||||
padding: 12px 15px;
|
||||
margin: 6px 0 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
animation: panel-in 160ms ease-out;
|
||||
}
|
||||
.semantic-content strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
#semantic-progress {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
margin-top: 12px;
|
||||
accent-color: var(--brand);
|
||||
}
|
||||
.semantic-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.semantic-actions button {
|
||||
font-size: 12px;
|
||||
}
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
#folder-form {
|
||||
background: #eef2e9;
|
||||
padding: 18px;
|
||||
padding: 14px 16px;
|
||||
margin: 8px 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
animation: panel-in 160ms ease-out;
|
||||
}
|
||||
#folder-form label {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.folder-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.folder-row input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: 9px;
|
||||
border: 1px solid #c9d3c3;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.hint {
|
||||
color: #74806e;
|
||||
.folder-row button {
|
||||
font-size: 12px;
|
||||
}
|
||||
#notice {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
margin: 4px 8px;
|
||||
}
|
||||
#notice:empty {
|
||||
display: none;
|
||||
}
|
||||
.error,
|
||||
#semantic-status.error {
|
||||
color: var(--error);
|
||||
}
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(250px, 1fr);
|
||||
gap: 28px;
|
||||
border-top: 1px solid #e1e6db;
|
||||
padding-top: 20px;
|
||||
grid-template-columns: minmax(0, 0.96fr) minmax(0, 1.04fr);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-top: 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px 10px 0 0;
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
}
|
||||
.workspace:has(aside[hidden]) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.results-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
background: var(--canvas);
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
color: var(--muted);
|
||||
}
|
||||
#result-summary {
|
||||
margin: 0;
|
||||
padding: 12px 15px 9px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
#results {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
padding: 0 8px 8px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
#results:empty {
|
||||
display: none;
|
||||
}
|
||||
#results li {
|
||||
padding: 3px 0;
|
||||
}
|
||||
.result {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-columns: 42px minmax(0, 1fr) auto;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
min-height: 96px;
|
||||
padding: 13px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
color: inherit;
|
||||
gap: 8px;
|
||||
border-radius: 8px;
|
||||
gap: 5px 12px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.result:hover {
|
||||
background: #edf1e7;
|
||||
background: var(--subtle);
|
||||
}
|
||||
.result.selected {
|
||||
background: white;
|
||||
border-color: #d1dbc9;
|
||||
box-shadow: 0 3px 12px #314b2810;
|
||||
background: var(--selected);
|
||||
border-color: #2563ff20;
|
||||
}
|
||||
.result .file-icon {
|
||||
grid-row: 1/4;
|
||||
align-self: start;
|
||||
}
|
||||
.file-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 7px;
|
||||
background: var(--subtle);
|
||||
color: var(--brand);
|
||||
}
|
||||
.file-icon svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
.file-icon[data-type="pdf"] {
|
||||
color: #e12e3d;
|
||||
}
|
||||
.file-icon[data-type="code"] {
|
||||
color: var(--muted);
|
||||
}
|
||||
.result strong {
|
||||
font-size: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
grid-column: 2;
|
||||
align-self: center;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.path {
|
||||
font-size: 11px;
|
||||
color: #79847a;
|
||||
overflow-wrap: anywhere;
|
||||
.kind {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.result .path,
|
||||
.snippet {
|
||||
grid-column: 1/-1;
|
||||
.result .kind {
|
||||
grid-column: 3;
|
||||
align-self: center;
|
||||
color: var(--brand);
|
||||
}
|
||||
.snippet {
|
||||
grid-column: 2/4;
|
||||
font-size: 12px;
|
||||
color: #61705f;
|
||||
line-height: 1.55;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.kind {
|
||||
font-size: 10px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 5px;
|
||||
background: #e6eddf;
|
||||
color: #638054;
|
||||
.path {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.result .path {
|
||||
grid-column: 2/4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.new-row {
|
||||
animation: row-in 160ms ease-out;
|
||||
}
|
||||
#empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 45px 30px;
|
||||
color: var(--muted);
|
||||
}
|
||||
#empty > .icon {
|
||||
margin-bottom: 12px;
|
||||
color: var(--brand);
|
||||
}
|
||||
#empty > .icon svg {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
#empty h2 {
|
||||
margin: 7px 0;
|
||||
color: var(--ink);
|
||||
font-size: 19px;
|
||||
letter-spacing: -0.45px;
|
||||
font-weight: 600;
|
||||
}
|
||||
#empty p {
|
||||
max-width: 420px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
aside {
|
||||
padding: 18px 22px;
|
||||
border-left: 1px solid #dce3d5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--line);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--canvas);
|
||||
}
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
padding: 18px 20px 10px;
|
||||
}
|
||||
.preview-heading {
|
||||
min-width: 0;
|
||||
}
|
||||
aside h2 {
|
||||
font-size: 20px;
|
||||
margin: 0 0 5px;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
aside pre {
|
||||
font: 13px/1.8 inherit;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
.preview-heading .path {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.preview-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 0 20px 14px;
|
||||
}
|
||||
#preview-kind {
|
||||
font-size: 12px;
|
||||
color: #67875f;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
#empty {
|
||||
text-align: center;
|
||||
padding: 38px 18px;
|
||||
color: #91a088;
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
#empty > span {
|
||||
font-size: 55px;
|
||||
.preview-actions button {
|
||||
font-size: 11px;
|
||||
padding: 6px 9px;
|
||||
}
|
||||
#empty h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: #607358;
|
||||
.preview-actions .icon svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
#empty p {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
.passage {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 0 10px 10px;
|
||||
padding: 22px 25px;
|
||||
overflow: auto;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
#notice {
|
||||
min-height: 18px;
|
||||
font-size: 12px;
|
||||
color: #627a5c;
|
||||
.passage > .eyebrow {
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
#notice.error {
|
||||
color: #a54639;
|
||||
aside pre {
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.9;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
margin: 0;
|
||||
}
|
||||
footer {
|
||||
color: #95a18e;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 13px 20px;
|
||||
min-height: 50px;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
border-top: 1px solid #e1e6db;
|
||||
margin-top: 32px;
|
||||
}
|
||||
button:focus-visible,
|
||||
input:focus-visible {
|
||||
outline: 2px solid #5b8357;
|
||||
outline-offset: 3px;
|
||||
.footer-privacy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.footer-privacy > .icon {
|
||||
color: var(--ink);
|
||||
}
|
||||
.privacy {
|
||||
font-size: 10px;
|
||||
border-left: 1px solid var(--line);
|
||||
padding-left: 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.keyboard-hints {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.keyboard-hints > span:not(.icon) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
@@ -274,70 +675,94 @@ input:focus-visible {
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
header,
|
||||
footer {
|
||||
padding: 20px;
|
||||
@keyframes row-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(3px);
|
||||
}
|
||||
main {
|
||||
padding: 12px 20px;
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@media (max-width: 820px) {
|
||||
.index-status {
|
||||
display: none;
|
||||
}
|
||||
.header-actions {
|
||||
gap: 5px;
|
||||
}
|
||||
.quiet-button > span:not(.icon) {
|
||||
display: none;
|
||||
}
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
aside {
|
||||
border-left: 0;
|
||||
border-top: 1px solid #dce3d5;
|
||||
.keyboard-hints > span {
|
||||
display: none;
|
||||
}
|
||||
.folder-row {
|
||||
flex-wrap: wrap;
|
||||
.privacy {
|
||||
display: none;
|
||||
}
|
||||
.folder-row input {
|
||||
flex-basis: 100%;
|
||||
.preview-actions button > span:not(.icon) {
|
||||
display: none;
|
||||
}
|
||||
footer {
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
.preview-header {
|
||||
padding: 14px 14px 8px;
|
||||
}
|
||||
.preview-toolbar {
|
||||
padding: 0 14px 12px;
|
||||
}
|
||||
.passage {
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
.filters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
.filters-grid label {
|
||||
font-size: 11px;
|
||||
color: #68785f;
|
||||
}
|
||||
.filters-grid input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
padding: 8px;
|
||||
border: 1px solid #d5decd;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
}
|
||||
details summary {
|
||||
font-size: 12px;
|
||||
color: #61795a;
|
||||
cursor: pointer;
|
||||
}
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.preview-actions button {
|
||||
font-size: 12px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d3decb;
|
||||
border-radius: 6px;
|
||||
background: #e9efdf;
|
||||
color: #36583c;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.filters-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@media (forced-colors: active) {
|
||||
:root {
|
||||
--canvas: Canvas;
|
||||
--surface: Canvas;
|
||||
--subtle: ButtonFace;
|
||||
--ink: CanvasText;
|
||||
--muted: CanvasText;
|
||||
--line: ButtonBorder;
|
||||
--selected: Highlight;
|
||||
--brand: LinkText;
|
||||
--error: CanvasText;
|
||||
}
|
||||
.result.selected {
|
||||
color: HighlightText;
|
||||
border-color: Highlight;
|
||||
}
|
||||
.result.selected .path,
|
||||
.result.selected .kind {
|
||||
color: HighlightText;
|
||||
}
|
||||
.file-icon {
|
||||
border: 1px solid ButtonBorder;
|
||||
}
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
summary:focus-visible {
|
||||
outline-color: Highlight;
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,69 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { LatestLane, AutomaticIndexGate, emptySearchState } from '../src/search-state.ts';
|
||||
const settle = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
test('coalesces queued keystrokes and discards stale responses', async () => {
|
||||
let revision = 1; let release; const executed = []; const accepted = [];
|
||||
const lane = new LatestLane(async value => {
|
||||
executed.push(value);
|
||||
if (value === 1) await new Promise(resolve => { release = resolve; });
|
||||
if (value === revision) accepted.push(value);
|
||||
}, value => value === revision, () => assert.fail('Unexpected failure'));
|
||||
lane.submit(1); revision = 2; lane.submit(2); revision = 3; lane.submit(3);
|
||||
release(); await settle();
|
||||
assert.deepEqual(executed, [1, 3]); assert.deepEqual(accepted, [3]);
|
||||
});
|
||||
|
||||
test('Escape invalidates both active and queued work', async () => {
|
||||
let revision = 1; let release; const accepted = [];
|
||||
const lane = new LatestLane(async value => {
|
||||
await new Promise(resolve => { release = resolve; });
|
||||
if (value === revision) accepted.push(value);
|
||||
}, value => value === revision, () => assert.fail('Unexpected failure'));
|
||||
lane.submit(1); lane.submit(1); revision++; lane.clear(); release(); await settle();
|
||||
assert.deepEqual(accepted, []);
|
||||
});
|
||||
|
||||
test('busy retries are bounded and stop when superseded', async () => {
|
||||
let attempts = 0; let errors = 0;
|
||||
const lane = new LatestLane(async () => { attempts++; throw new Error('worker busy'); }, () => true, () => errors++, async () => {});
|
||||
lane.submit(1); await settle(); assert.equal(attempts, 2); assert.equal(errors, 1);
|
||||
let revision = 1; let release; const executed = [];
|
||||
const superseded = new LatestLane(async value => { executed.push(value); if (value === 1) throw new Error('busy'); }, value => value === revision, () => assert.fail('Unexpected failure'), () => new Promise(resolve => { release = resolve; }));
|
||||
superseded.submit(1); await settle(); revision = 2; superseded.submit(2); release(); await settle();
|
||||
assert.deepEqual(executed, [1, 2]);
|
||||
});
|
||||
|
||||
test('automatic rebuild failures wait for a changed revision', () => {
|
||||
const gate = new AutomaticIndexGate();
|
||||
assert.equal(gate.claim('generation1:pending4:folder1'), true);
|
||||
assert.equal(gate.claim('generation1:pending4:folder1'), false);
|
||||
assert.equal(gate.claim('generation1:pending4:folder2'), true);
|
||||
});
|
||||
|
||||
test('empty keyword results stay provisional while semantic setup or search is pending', () => {
|
||||
for (const phase of ['preparing', 'indexing', 'pending']) {
|
||||
const state = emptySearchState('lexical', phase);
|
||||
assert.equal(state.title, 'No keyword matches yet.');
|
||||
assert.match(state.detail, /preparing|indexing|Searching by meaning/);
|
||||
assert.doesNotMatch(state.title, /No matching files|No matches for this search/);
|
||||
}
|
||||
});
|
||||
|
||||
test('only a completed hybrid search gives a final no-match message', () => {
|
||||
const complete = emptySearchState('hybrid', 'complete');
|
||||
assert.equal(complete.title, 'No matches for this search.');
|
||||
assert.match(complete.detail, /filters/);
|
||||
const rebuilding = emptySearchState('hybrid', 'indexing');
|
||||
assert.equal(rebuilding.title, 'No matches in the current index yet.');
|
||||
assert.match(rebuilding.detail, /may change/);
|
||||
for (const phase of ['preparing', 'indexing', 'pending', 'unavailable', 'no-content']) {
|
||||
assert.notEqual(emptySearchState('hybrid', phase).title, complete.title);
|
||||
}
|
||||
});
|
||||
|
||||
test('no document content and unavailable semantics have actionable distinct guidance', () => {
|
||||
assert.match(emptySearchState('lexical', 'no-content').detail, /readable documents/);
|
||||
assert.match(emptySearchState('lexical', 'unavailable').detail, /retry the search/);
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import { defineConfig } from "vite";
|
||||
export default defineConfig({
|
||||
server: { port: 1420, strictPort: true },
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
watch: { ignored: ["**/src-tauri/**", "**/node_modules/**"] },
|
||||
},
|
||||
clearScreen: false,
|
||||
});
|
||||
Reference in new issue
Block a user