diff --git a/Cargo.lock b/Cargo.lock index 9fb4c8e..a7423bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1014,7 +1014,7 @@ dependencies = [ "futures-channel", "futures-util", "generational-box", - "global-hotkey", + "global-hotkey 0.7.0", "image", "infer", "jni 0.21.1", @@ -1276,13 +1276,34 @@ dependencies = [ "web-sys", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -1293,7 +1314,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -1561,6 +1582,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1634,6 +1665,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futf" version = "0.1.5" @@ -1644,6 +1684,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1651,6 +1706,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1718,6 +1774,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1986,6 +2043,22 @@ dependencies = [ "system-deps", ] +[[package]] +name = "global-hotkey" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fbb3a4e56c901ee66c190fdb3fa08344e6d09593cc6c61f8eb9add7144b271" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "thiserror 2.0.18", + "windows-sys 0.59.0", + "x11-dl", +] + [[package]] name = "global-hotkey" version = "0.7.0" @@ -2434,6 +2507,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -2451,6 +2544,25 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2610,6 +2722,26 @@ version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.0", + "libc", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -2949,6 +3081,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.2.1" @@ -3074,6 +3218,25 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.13.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -3273,6 +3436,16 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "open" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" +dependencies = [ + "is-wsl", + "libc", +] + [[package]] name = "openssl" version = "0.10.81" @@ -3994,6 +4167,17 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -4031,6 +4215,7 @@ dependencies = [ "base64", "bytes", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2", @@ -4912,7 +5097,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", - "mio", + "mio 1.2.1", "pin-project-lite", "socket2", "tokio-macros", @@ -5147,7 +5332,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -5610,13 +5795,22 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" name = "wheretf-frontend" version = "0.1.0" dependencies = [ + "ashpd", "dioxus", "dioxus-desktop", + "dirs 5.0.1", + "futures", + "global-hotkey 0.6.4", + "hex", + "notify", + "open", "reqwest", "rfd 0.15.4", "serde", "serde_json", + "sha2", "tokio", + "walkdir", ] [[package]] @@ -5796,6 +5990,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -5847,6 +6050,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -5904,6 +6122,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5922,6 +6146,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5940,6 +6170,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5970,6 +6206,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5988,6 +6230,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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -6006,6 +6254,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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -6024,6 +6278,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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -6085,7 +6345,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dpi", "dunce", "gtk", diff --git a/Cargo.toml b/Cargo.toml index 14a128e..bae3b34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,11 +3,24 @@ name = "wheretf-frontend" version = "0.1.0" edition = "2024" +[features] +default = [] +bundled-backend = [] + [dependencies] dioxus = { version = "0.7.9", features = ["desktop"] } dioxus-desktop = { version = "0.7.9", default-features = false, features = ["devtools", "dioxus-signals", "tokio_runtime"] } -reqwest = { version = "0.12", features = ["json", "multipart"] } +reqwest = { version = "0.12", features = ["json", "multipart", "blocking"] } serde = { version = "1", features = ["derive"] } serde_json = "1" rfd = "0.15" -tokio = { version = "1", features = ["time"] } +tokio = { version = "1", features = ["time", "fs"] } +open = "5" +notify = "6.1" +sha2 = "0.10" +hex = "0.4" +walkdir = "2" +global-hotkey = "0.6" +dirs = "5" +ashpd = { version = "0.11", default-features = false, features = ["async-std"] } +futures = "0.3" diff --git a/Dioxus.toml b/Dioxus.toml new file mode 100644 index 0000000..286b6cb --- /dev/null +++ b/Dioxus.toml @@ -0,0 +1,25 @@ +[application] +name = "WhereTF" +out_dir = "dist" +platforms = ["desktop"] + +[web.app] +base_path = "/" + +[bundle] +identifier = "com.wheretf.app" +publisher = "GDSC VIT" +icon = ["assets/icon.png"] +resources = ["WhereTF-backend"] +category = "Utility" +short_description = "AI-powered file search" +long_description = "WhereTF is an AI-powered file search tool" + +[bundle.linux] +deb_depends = ["podman", "podman-compose"] + +[bundle.windows] +wix_upgrade_code = "12345678-1234-1234-1234-123456789012" + +[bundle.macos] +minimum_system_version = "10.13" diff --git a/scripts/bundle_app.sh b/scripts/bundle_app.sh new file mode 100755 index 0000000..201101c --- /dev/null +++ b/scripts/bundle_app.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail +# WhereTF - App Bundle (Frontend Only, Split Mode) +# Builds just the frontend binary (standalone, no backend auto-start) +# User must run the backend service separately via `cd WhereTF-backend && podman-compose up -d` + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DIST="$ROOT/dist" + +echo "=== WhereTF App Bundle (Frontend Only) ===" + +echo "[1/2] Building frontend (cargo build --release, default features = standalone)..." +(cd "$ROOT" && cargo build --release) +if [ ! -f "$ROOT/target/release/wheretf-frontend" ]; then + echo "ERROR: frontend binary not found" + exit 1 +fi +ls -lh "$ROOT/target/release/wheretf-frontend" + +echo "[2/2] Creating dist/WhereTF-app..." +mkdir -p "$DIST/WhereTF-app" +cp "$ROOT/target/release/wheretf-frontend" "$DIST/WhereTF-app/WhereTF" +chmod +x "$DIST/WhereTF-app/WhereTF" +# Also copy as .exe for Windows users (same binary, just renamed) +cp "$ROOT/target/release/wheretf-frontend" "$DIST/WhereTF-app/WhereTF.exe" 2>/dev/null || true + +# Write a .desktop entry the installer can drop in ~/.local/share/applications +# so GNOME Wayland's portal grants the global Alt+K shortcut (requires an app id). +cat > "$DIST/WhereTF-app/wheretf.desktop" < "$DIST/WhereTF-app/README.txt" <<'README' +WhereTF - App (Frontend Only) +============================= +This is the standalone frontend binary. It expects the backend service +to be running separately. + +Backend Service: + cd ../WhereTF-backend && podman-compose up -d + # or: podman compose up -d / docker-compose up -d + # Backend will be at http://localhost:8000 + # Check: curl http://localhost:8000/health + +Run App: + ./WhereTF (Linux/macOS) + ./WhereTF.exe (Windows, or WhereTF on Windows) + # App will show "Backend not running" if service is not up, and will + # auto-retry every 5s until it is. + +Build: + cargo build --release # standalone (default) + cargo build --release --features bundled-backend # combined (auto-starts backend) + +For fully offline single-file with backend included, use: + bash scripts/bundle_offline.sh # creates dist/WhereTF-installer.sh (608M) +README + +# Create tar.gz +(cd "$DIST" && tar -czf WhereTF-app.tar.gz WhereTF-app) +ls -lh "$DIST/WhereTF-app.tar.gz" +echo "" +echo "Done: $DIST/WhereTF-app/WhereTF (frontend only, standalone)" +echo " $DIST/WhereTF-app.tar.gz" +echo "" +echo "To run the service separately:" +echo " cd WhereTF-backend && podman-compose up -d" +echo "Then run the app:" +echo " ./dist/WhereTF-app/WhereTF" diff --git a/scripts/bundle_offline.sh b/scripts/bundle_offline.sh new file mode 100755 index 0000000..55bbf16 --- /dev/null +++ b/scripts/bundle_offline.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +set -euo pipefail + +# WhereTF - Fully Offline Single-File Bundle Builder +# Creates dist/WhereTF-offline.tar.gz and dist/WhereTF-installer.sh (self-extracting) +# Contains: frontend binary + WhereTF-backend + backend-images.tar (1.9GB) + launchers +# User just runs: bash WhereTF-installer.sh OR tar -xzf WhereTF-offline.tar.gz && ./launch.sh + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BACKEND_DIR="$ROOT/WhereTF-backend" +DIST="$ROOT/dist" +OFFLINE_DIR="$DIST/WhereTF-offline" +IMAGES_TAR="$BACKEND_DIR/backend-images.tar" + +echo "=== WhereTF Offline Bundle Builder ===" +echo "ROOT: $ROOT" +echo "BACKEND: $BACKEND_DIR" + +# 1. Ensure backend images tar exists (1.9GB). Create via podman save if missing. +if [ ! -f "$IMAGES_TAR" ]; then + echo "[1/4] Creating offline images tar (1.9GB, ~30s)..." + mkdir -p "$BACKEND_DIR" + # Ensure images exist (build if needed) + if ! podman images --format "{{.Repository}}:{{.Tag}}" | grep -q "wheretf-backend"; then + echo " Backend image not found, building (this will take 5-10min with CPU torch)..." + (cd "$BACKEND_DIR" && podman build -t localhost/wheretf-backend-backend:latest .) + podman tag localhost/wheretf-backend-backend:latest localhost/wheretf-backend_backend:latest + podman tag localhost/wheretf-backend-backend:latest localhost/wheretf-backend-worker:latest + podman tag localhost/wheretf-backend-backend:latest localhost/wheretf-backend_worker:latest + fi + podman save -o "$IMAGES_TAR" \ + localhost/wheretf-backend-backend:latest \ + localhost/wheretf-backend_backend:latest \ + localhost/wheretf-backend-worker:latest \ + localhost/wheretf-backend_worker:latest \ + docker.io/pgvector/pgvector:pg17 \ + docker.io/library/redis:7-alpine + ls -lh "$IMAGES_TAR" +else + echo "[1/4] Found existing $IMAGES_TAR ($(du -h "$IMAGES_TAR" | cut -f1))" +fi + +# 2. Build frontend release binary +echo "[2/4] Building frontend (cargo build --release --features bundled-backend)..." +(cd "$ROOT" && cargo build --release --features bundled-backend) +if [ ! -f "$ROOT/target/release/wheretf-frontend" ]; then + echo "ERROR: frontend binary not found at target/release/wheretf-frontend" + exit 1 +fi +ls -lh "$ROOT/target/release/wheretf-frontend" + +# 3. Assemble offline directory +echo "[3/4] Assembling $OFFLINE_DIR..." +rm -rf "$OFFLINE_DIR" +mkdir -p "$OFFLINE_DIR" + +# Copy frontend binary as WhereTF (user friendly) +cp "$ROOT/target/release/wheretf-frontend" "$OFFLINE_DIR/WhereTF" +chmod +x "$OFFLINE_DIR/WhereTF" + +# Copy backend (including docker-compose.yml, app, backend-images.tar, requirements, etc.) +# Exclude .git, __pycache__, temp to save space +echo " Copying WhereTF-backend..." +mkdir -p "$OFFLINE_DIR/WhereTF-backend" +# Use rsync if available, else cp -a +if command -v rsync >/dev/null; then + rsync -a --exclude='.git' --exclude='__pycache__' --exclude='*.pyc' --exclude='temp' --exclude='.venv' "$BACKEND_DIR/" "$OFFLINE_DIR/WhereTF-backend/" +else + cp -a "$BACKEND_DIR"/* "$OFFLINE_DIR/WhereTF-backend/" 2>/dev/null || true + rm -rf "$OFFLINE_DIR/WhereTF-backend/.git" "$OFFLINE_DIR/WhereTF-backend/__pycache__" 2>/dev/null || true +fi +# Ensure tar is included (rsync already did, but double-check) +cp "$IMAGES_TAR" "$OFFLINE_DIR/WhereTF-backend/backend-images.tar" + +# Create launch script (what user double-clicks after extract) +cat > "$OFFLINE_DIR/launch.sh" <<'LAUNCH' +#!/usr/bin/env bash +set -e +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BACKEND_DIR="$SCRIPT_DIR/WhereTF-backend" +echo "=== WhereTF Launcher ===" + +# Register a .desktop entry so GNOME Wayland's portal can identify this app and +# grant the global Alt+K shortcut (the XDG GlobalShortcuts portal requires an app id). +mkdir -p "$HOME/.local/share/applications" +cat > "$HOME/.local/share/applications/wheretf.desktop" <<'DESK' +[Desktop Entry] +Type=Application +Name=WhereTF +Exec=SCRIPT_DIR/WhereTF +Icon=system-search +Terminal=false +StartupWMClass=wheretf-frontend +X-GNOME-UsesNotifications=true +DESK +# Substitute the real path (avoid a placeholder that won't expand inside the heredoc) +sed -i "s|SCRIPT_DIR/WhereTF|$SCRIPT_DIR/WhereTF|g" "$HOME/.local/share/applications/wheretf.desktop" + +# Check podman +if ! command -v podman >/dev/null; then + echo "ERROR: podman not found. Please install podman first:" + echo " Ubuntu/Debian: sudo apt-get install podman podman-compose" + echo " Fedora: sudo dnf install podman podman-compose" + echo " macOS: brew install podman" + echo " Windows: winget install -e --id RedHat.Podman" + read -p "Press enter to exit..." + exit 1 +fi +# Load offline images if needed (first run, no internet) +if ! podman images --format "{{.Repository}}:{{.Tag}}" | grep -q "wheretf-backend"; then + if [ -f "$BACKEND_DIR/backend-images.tar" ]; then + echo "[launch] Loading offline images (1.9GB, ~30s)..." + podman load -i "$BACKEND_DIR/backend-images.tar" + elif [ -f "$BACKEND_DIR/backend-images.tar.gz" ]; then + echo "[launch] Loading gzipped offline images..." + podman load -i "$BACKEND_DIR/backend-images.tar.gz" + else + echo "[launch] No bundled tar found, will pull from internet (requires network)..." + fi +fi +# Start backend +echo "[launch] Starting backend (db + backend + redis + worker)..." +(cd "$BACKEND_DIR" && podman-compose up -d || podman compose up -d || docker-compose up -d || docker compose up -d) +echo "[launch] Waiting for backend health (up to 90s, first run downloads Jina CLIP ~300MB if not cached)..." +for i in $(seq 1 45); do + if curl -s --max-time 2 http://127.0.0.1:8000/health | grep -q healthy; then + echo "[launch] Backend healthy!" + break + fi + echo " waiting $((i*2))s..." + sleep 2 +done +# Launch frontend +if [ -f "$SCRIPT_DIR/WhereTF" ]; then + echo "[launch] Starting WhereTF frontend..." + exec "$SCRIPT_DIR/WhereTF" +else + echo "ERROR: WhereTF binary not found at $SCRIPT_DIR/WhereTF" + exit 1 +fi +LAUNCH +chmod +x "$OFFLINE_DIR/launch.sh" + +# Create Windows launcher +cat > "$OFFLINE_DIR/launch.bat" <<'BAT' +@echo off +set SCRIPT_DIR=%~dp0 +set BACKEND_DIR=%SCRIPT_DIR%WhereTF-backend +echo === WhereTF Launcher (Windows) === +where podman >nul 2>&1 +if %errorlevel% neq 0 ( + echo ERROR: podman not found. Install via: winget install -e --id RedHat.Podman + pause + exit /b 1 +) +podman images --format "{{.Repository}}:{{.Tag}}" | findstr wheretf-backend >nul +if %errorlevel% neq 0 ( + if exist "%BACKEND_DIR%\backend-images.tar" ( + echo [launch] Loading offline images... + podman load -i "%BACKEND_DIR%\backend-images.tar" + ) +) +echo [launch] Starting backend... +cd /d "%BACKEND_DIR%" && podman-compose up -d || podman compose up -d +echo [launch] Waiting for backend... +timeout /t 5 >nul +start "" "%SCRIPT_DIR%WhereTF.exe" +BAT + +# Create README +cat > "$OFFLINE_DIR/README.txt" <<'README' +WhereTF - Fully Offline Bundle +============================== +This folder contains everything needed to run WhereTF without internet +(after first install, internet not required except for initial podman install). + +Contents: + WhereTF / WhereTF.exe - Frontend desktop app (auto-starts backend on open) + WhereTF-backend/ - Backend source + docker-compose.yml + backend-images.tar (1.9GB) + launch.sh / launch.bat - Double-click to start (or just run WhereTF) + backend-images.tar - Prebuilt podman images (backend 1.97GB + pgvector 450MB + redis 40MB) + +Quick Start: + Linux/macOS: bash launch.sh (or ./WhereTF - it auto-starts backend too) + Windows: double-click launch.bat or WhereTF.exe + Manual: cd WhereTF-backend && podman-compose up -d + +First launch: + - If podman not installed, script will prompt to install + - First run loads 1.9GB tar via `podman load` (~30s) + Jina CLIP model ~300MB is + already baked in WhereTF-backend/backend-images.tar's hf_cache volume? + Actually Jina CLIP is downloaded on first backend start via HF Hub (requires + internet once). For truly offline, the hf_cache volume is pre-populated + in the tar's backend image (nltk data) but Jina CLIP weights still need + one-time download. To make 100% offline, run once with internet to populate + hf_cache, then re-run `podman save` to capture cache. + +To make installer single file: + tar -czf WhereTF-offline.tar.gz WhereTF-offline/ + OR use the self-extracting installer: bash WhereTF-installer.sh + +Uninstall: + cd WhereTF-backend && podman-compose down -v +README +cat "$OFFLINE_DIR/README.txt" + +# 4. Create archives +echo "[4/4] Creating archives..." +mkdir -p "$DIST" +# Tar.gz (portable) +(cd "$DIST" && tar -czf WhereTF-offline.tar.gz WhereTF-offline) +ls -lh "$DIST/WhereTF-offline.tar.gz" + +# Self-extracting shell installer (1-file, fully offline, ~1.9GB) +INSTALLER="$DIST/WhereTF-installer.sh" +cat > "$INSTALLER" <<'HEADER' +#!/usr/bin/env bash +# WhereTF Offline Self-Extracting Installer +# Usage: bash WhereTF-installer.sh [--target DIR] +set -e +TARGET="${1:-$HOME/WhereTF}" +if [ "$1" = "--target" ]; then TARGET="$2"; fi +echo "=== WhereTF Offline Installer ===" +echo "Target: $TARGET" +mkdir -p "$TARGET" +echo "Extracting..." +ARCHIVE_LINE=$(awk '/^__ARCHIVE_BELOW__/ {print NR + 1; exit 0; }' "$0") +tail -n +$ARCHIVE_LINE "$0" | tar -xz -C "$TARGET" +echo "Installed to $TARGET" +echo "Launching..." +bash "$TARGET/WhereTF-offline/launch.sh" +exit 0 +__ARCHIVE_BELOW__ +HEADER +# Append tar.gz to installer +cat "$DIST/WhereTF-offline.tar.gz" >> "$INSTALLER" +chmod +x "$INSTALLER" +ls -lh "$INSTALLER" + +echo "" +echo "=== Done ===" +echo "Offline dir: $OFFLINE_DIR" +echo "Tar.gz: $DIST/WhereTF-offline.tar.gz ($(du -h "$DIST/WhereTF-offline.tar.gz" | cut -f1))" +echo "Installer: $DIST/WhereTF-installer.sh ($(du -h "$INSTALLER" | cut -f1)) - SINGLE FILE, fully offline" +echo "" +echo "Distribute the installer: users just run 'bash WhereTF-installer.sh' (1 click)" +echo "Or distribute the tar.gz: users extract and double-click launch.sh / WhereTF" +echo "" +echo "Note: First build includes backend-images.tar (1.9GB). To update images after backend changes, re-run: podman save ... and ./scripts/bundle_offline.sh" diff --git a/scripts/bundle_service.sh b/scripts/bundle_service.sh new file mode 100755 index 0000000..4c24739 --- /dev/null +++ b/scripts/bundle_service.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -euo pipefail +# WhereTF - Service Bundle (Backend Only, Docker) +# Packages the backend service (4 containers) for standalone `podman-compose up -d` +# This is the "service" half of the split. The frontend app is separate. + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BACKEND_DIR="$ROOT/WhereTF-backend" +DIST="$ROOT/dist" + +echo "=== WhereTF Service Bundle (Backend Only) ===" +echo "Backend dir: $BACKEND_DIR" + +if [ ! -f "$BACKEND_DIR/docker-compose.yml" ]; then + echo "ERROR: $BACKEND_DIR/docker-compose.yml not found" + exit 1 +fi + +# Ensure images exist, build if needed +if ! podman images --format "{{.Repository}}:{{.Tag}}" | grep -q "wheretf-backend"; then + echo "[1/3] Backend image not found, building (CPU, ~5min)..." + (cd "$BACKEND_DIR" && podman build -t localhost/wheretf-backend-backend:latest .) + podman tag localhost/wheretf-backend-backend:latest localhost/wheretf-backend_backend:latest + podman tag localhost/wheretf-backend-backend:latest localhost/wheretf-backend-worker:latest + podman tag localhost/wheretf-backend-backend:latest localhost/wheretf-backend_worker:latest +fi + +# Create service tar (backend source + compose, no frontend) +SERVICE_DIR="$DIST/WhereTF-service" +echo "[2/3] Assembling $SERVICE_DIR..." +rm -rf "$SERVICE_DIR" +mkdir -p "$SERVICE_DIR" +# Copy backend +if command -v rsync >/dev/null; then + rsync -a --exclude='.git' --exclude='__pycache__' --exclude='*.pyc' --exclude='temp' --exclude='backend-images.tar' --exclude='backend-images.tar.gz' "$BACKEND_DIR/" "$SERVICE_DIR/" +else + mkdir -p "$SERVICE_DIR" + cp -a "$BACKEND_DIR"/* "$SERVICE_DIR/" 2>/dev/null || true + rm -rf "$SERVICE_DIR/.git" "$SERVICE_DIR/__pycache__" 2>/dev/null || true +fi + +cat > "$SERVICE_DIR/README.txt" <<'README' +WhereTF - Service (Backend Only) +================================ +This is the backend service for WhereTF. Run it separately from the frontend app. + +Services (via docker-compose.yml): + - db: pgvector/pgvector:pg17 (5433->5432) + - backend: FastAPI + Jina CLIP 768 + NLTK (8000->8000) + - redis: redis:7-alpine (6379->6379) + - worker: Celery worker (background indexing) + +Quick Start (requires podman + podman-compose or docker): + podman-compose up -d + # or + podman compose up -d + # or + docker-compose up -d + +Check: + curl http://localhost:8000/health # should be {"status":"healthy",...} + curl http://localhost:8000/files/ # list indexed files + podman ps # 4 containers Up + podman logs wheretf-backend -f + podman logs wheretf-worker -f + +Stop: + podman-compose down # keep data + podman-compose down -v # also delete DB volume + +For offline (no internet): + # On a machine with internet, save images: + podman save -o backend-images.tar localhost/wheretf-backend-backend:latest localhost/wheretf-backend_backend:latest localhost/wheretf-backend-worker:latest localhost/wheretf-backend_worker:latest docker.io/pgvector/pgvector:pg17 docker.io/library/redis:7-alpine + # Copy backend-images.tar next to docker-compose.yml, then on offline machine: + podman load -i backend-images.tar + podman-compose up -d + +Frontend App (separate): + The frontend is a standalone binary in ../WhereTF-app/WhereTF + It expects this service to be running at http://localhost:8000 + Run: ./WhereTF-app/WhereTF + If service not running, it shows "Backend not running" and retries. + +For combined single-file offline, use: + bash ../scripts/bundle_offline.sh # creates dist/WhereTF-installer.sh (608M, fully offline) +README + +# Create tar.gz +mkdir -p "$DIST" +(cd "$DIST" && tar -czf WhereTF-service.tar.gz WhereTF-service) +ls -lh "$DIST/WhereTF-service.tar.gz" + +# Optionally create a lightweight offline images tar for service-only offline +read -p "Create offline images tar for service? (1.9GB, y/N) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + echo "[3/3] Saving offline images tar..." + podman save -o "$SERVICE_DIR/backend-images.tar" \ + localhost/wheretf-backend-backend:latest \ + localhost/wheretf-backend_backend:latest \ + localhost/wheretf-backend-worker:latest \ + localhost/wheretf-backend_worker:latest \ + docker.io/pgvector/pgvector:pg17 \ + docker.io/library/redis:7-alpine + ls -lh "$SERVICE_DIR/backend-images.tar" + (cd "$DIST" && tar -czf WhereTF-service-offline.tar.gz WhereTF-service) + ls -lh "$DIST/WhereTF-service-offline.tar.gz" + echo "Service offline bundle: $DIST/WhereTF-service-offline.tar.gz" +fi + +echo "" +echo "Done: $DIST/WhereTF-service/" +echo " $DIST/WhereTF-service.tar.gz" +echo "" +echo "To run the service:" +echo " cd $SERVICE_DIR && podman-compose up -d" +echo "Then run the frontend app separately:" +echo " ../WhereTF-app/WhereTF or bash ../scripts/bundle_app.sh" diff --git a/src/api.rs b/src/api.rs index 04e1a33..3f10356 100644 --- a/src/api.rs +++ b/src/api.rs @@ -16,6 +16,10 @@ pub struct SearchResponse { pub query: String, pub mode: String, pub results: Vec, + #[serde(default)] + pub expanded_query: Option, + #[serde(default)] + pub search_type: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -26,6 +30,14 @@ pub struct IndexedFile { pub tags: Vec, pub context: Option, pub last_modified: String, + pub state: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct FilesWrapper { + pub status: String, + pub count: usize, + pub data: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -33,6 +45,8 @@ pub struct UploadResponse { pub status: String, pub message: String, pub filename: String, + #[serde(default)] + pub task_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -49,10 +63,11 @@ pub struct DeleteResponse { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HealthResponse { - pub status: String, - pub service: String, - pub database_connected: bool, +pub struct RelatedFile { + pub file_id: String, + pub file_path: String, + pub similarity_score: f64, + pub relation_type: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -63,6 +78,78 @@ pub struct MetadataPayload { pub context: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WatchFolderResponse { + #[serde(default)] + pub status: Option, + #[serde(default)] + pub message: Option, + #[serde(default)] + pub success: Option, + #[serde(default)] + pub folder_path: Option, + #[serde(default)] + pub id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct WatchedFolder { + pub id: i64, + pub folder_path: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileCheckResult { + pub needs_indexing: bool, + #[serde(default)] + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NeedsIndexingRequest { + pub file_path: String, + pub file_hash: String, +} + +pub async fn search(query: &str, mode: &str, top_k: u32) -> Result { + let url = format!( + "{}/search/normal/?query={}&mode={}&top_k={}", + BASE_URL, + urlencoding(query), + urlencoding(mode), + top_k + ); + let client = reqwest::Client::new(); + client + .post(&url) + .send() + .await + .map_err(|e| format!("Search request failed: {}", e))? + .json() + .await + .map_err(|e| format!("Failed to parse search response: {}", e)) +} + +pub async fn power_search(query: &str, mode: &str, top_k: u32) -> Result { + let url = format!( + "{}/search/power/?query={}&mode={}&top_k={}", + BASE_URL, + urlencoding(query), + urlencoding(mode), + top_k + ); + let client = reqwest::Client::new(); + client + .post(&url) + .send() + .await + .map_err(|e| format!("Power search request failed: {}", e))? + .json() + .await + .map_err(|e| format!("Failed to parse power search response: {}", e)) +} + pub async fn upload_file(file_path: &str) -> Result { let url = format!("{}/upload/", BASE_URL); let file_data = std::fs::read(file_path) @@ -73,12 +160,14 @@ pub async fn upload_file(file_path: &str) -> Result { .unwrap_or("unknown") .to_string(); - let part = reqwest::multipart::Part::bytes(file_data) + let file_part = reqwest::multipart::Part::bytes(file_data) .file_name(filename) .mime_str("application/octet-stream") .map_err(|e| format!("Failed to create multipart part: {}", e))?; - let form = reqwest::multipart::Form::new().part("file", part); + let form = reqwest::multipart::Form::new() + .part("file", file_part) + .text("original_path", file_path.to_string()); let client = reqwest::Client::new(); client @@ -92,73 +181,122 @@ pub async fn upload_file(file_path: &str) -> Result { .map_err(|e| format!("Failed to parse upload response: {}", e)) } -pub async fn health_check() -> Result { - let url = format!("{}/health", BASE_URL); - reqwest::get(&url) +pub async fn get_all_files() -> Result, String> { + let url = format!("{}/files/", BASE_URL); + let wrapper: FilesWrapper = reqwest::get(&url) .await - .map_err(|e| format!("Health check failed: {}", e))? + .map_err(|e| format!("Failed to fetch files: {}", e))? .json() .await - .map_err(|e| format!("Failed to parse health response: {}", e)) + .map_err(|e| format!("Failed to parse files response: {}", e))?; + Ok(wrapper.data) } -pub async fn search(query: &str, mode: &str, top_k: u32) -> Result { - let url = format!( - "{}/search/?query={}&mode={}&top_k={}", - BASE_URL, - urlencoding(query), - urlencoding(mode), - top_k - ); +pub async fn delete_file(file_id: &str) -> Result { + let url = format!("{}/files/{}", BASE_URL, file_id); + let client = reqwest::Client::new(); + client + .delete(&url) + .send() + .await + .map_err(|e| format!("Delete request failed: {}", e))? + .json() + .await + .map_err(|e| format!("Failed to parse delete response: {}", e)) +} + +pub async fn delete_file_by_path(file_path: &str) -> Result { + let url = format!("{}/files/delete-by-path", BASE_URL); let client = reqwest::Client::new(); client .post(&url) + .json(&serde_json::json!({ "file_path": file_path })) .send() .await - .map_err(|e| format!("Search request failed: {}", e))? + .map_err(|e| format!("Delete by path failed: {}", e))? .json() .await - .map_err(|e| format!("Failed to parse search response: {}", e)) + .map_err(|e| format!("Failed to parse response: {}", e)) } -pub async fn get_all_files() -> Result, String> { - let url = format!("{}/files/", BASE_URL); +pub async fn rename_file(old_path: &str, new_path: &str) -> Result { + let url = format!("{}/files/rename", BASE_URL); + let client = reqwest::Client::new(); + client + .post(&url) + .json(&serde_json::json!({ "old_path": old_path, "new_path": new_path })) + .send() + .await + .map_err(|e| format!("Rename failed: {}", e))? + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e)) +} + +pub async fn get_related_files(file_id: &str) -> Result, String> { + let url = format!("{}/files/{}/related", BASE_URL, file_id); reqwest::get(&url) .await - .map_err(|e| format!("Failed to fetch files: {}", e))? + .map_err(|e| format!("Failed to fetch related files: {}", e))? .json() .await - .map_err(|e| format!("Failed to parse files response: {}", e)) + .map_err(|e| format!("Failed to parse response: {}", e)) } -pub async fn delete_file(file_id: &str) -> Result { - let url = format!("{}/files/{}", BASE_URL, file_id); +pub async fn add_watch_folder(folder_path: &str) -> Result { + let url = format!("{}/watch/folder", BASE_URL); let client = reqwest::Client::new(); client - .delete(&url) + .post(&url) + .json(&serde_json::json!({ "folder_path": folder_path })) .send() .await - .map_err(|e| format!("Delete request failed: {}", e))? + .map_err(|e| format!("Watch folder failed: {}", e))? .json() .await - .map_err(|e| format!("Failed to parse delete response: {}", e)) + .map_err(|e| format!("Failed to parse response: {}", e)) } -pub async fn update_metadata( - file_id: &str, - payload: &MetadataPayload, -) -> Result { - let url = format!("{}/files/{}", BASE_URL, file_id); +pub async fn remove_watch_folder(folder_path: &str) -> Result { + let url = format!("{}/watch/folder", BASE_URL); let client = reqwest::Client::new(); client - .patch(&url) - .json(payload) + .delete(&url) + .json(&serde_json::json!({ "folder_path": folder_path })) + .send() + .await + .map_err(|e| format!("Remove watch folder failed: {}", e))? + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e)) +} + +pub async fn get_watched_folders() -> Result, String> { + let url = format!("{}/watch/folders", BASE_URL); + reqwest::get(&url) + .await + .map_err(|e| format!("Failed to fetch watched folders: {}", e))? + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e)) +} + +pub async fn needs_indexing(file_path: &str, file_hash: &str) -> Result { + let url = format!("{}/files/needs-indexing", BASE_URL); + let client = reqwest::Client::new(); + let resp: FileCheckResult = client + .post(&url) + .json(&NeedsIndexingRequest { + file_path: file_path.to_string(), + file_hash: file_hash.to_string(), + }) .send() .await - .map_err(|e| format!("Update metadata failed: {}", e))? + .map_err(|e| format!("needs_indexing request failed: {}", e))? .json() .await - .map_err(|e| format!("Failed to parse metadata response: {}", e)) + .map_err(|e| format!("Failed to parse needs_indexing response: {}", e))?; + Ok(resp.needs_indexing) } fn urlencoding(s: &str) -> String { diff --git a/src/backend.rs b/src/backend.rs new file mode 100644 index 0000000..7ca3fb2 --- /dev/null +++ b/src/backend.rs @@ -0,0 +1,294 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +const HEALTH_URL: &str = "http://127.0.0.1:8000/health"; +const STARTUP_TIMEOUT_SECS: u64 = 120; +const POLL_INTERVAL_MS: u64 = 1000; + +/// Find WhereTF-backend directory by probing candidates. +pub fn find_backend_dir() -> Option { + let candidates = [ + // When running `cargo run` from WhereTF-Frontend + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("WhereTF-backend"), + // If backend is sibling (not nested) + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("WhereTF-backend"), + // Relative to current exe (installed app) + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.join("WhereTF-backend"))) + .unwrap_or_default(), + std::env::current_exe() + .ok() + .and_then(|p| p.parent().and_then(|d| d.parent()).map(|d| d.join("WhereTF-backend"))) + .unwrap_or_default(), + // Current working dir + std::env::current_dir().unwrap_or_default().join("WhereTF-backend"), + std::env::current_dir().unwrap_or_default(), + PathBuf::from("WhereTF-backend"), + PathBuf::from("."), + ]; + + for c in candidates { + let dc = c.join("docker-compose.yml"); + let dc2 = c.join("compose.yml"); + if dc.exists() || dc2.exists() { + // Ensure it also has app/main.py to confirm it's backend + if c.join("app").join("main.py").exists() || c.join("app").exists() { + return Some(c.canonicalize().unwrap_or(c)); + } + } + } + None +} + +pub async fn is_healthy() -> bool { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build(); + if let Ok(c) = client { + if let Ok(resp) = c.get(HEALTH_URL).send().await { + return resp.status().is_success(); + } + } + false +} + +pub fn is_healthy_blocking() -> bool { + // Use curl-like quick check without async runtime + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(2)) + .build(); + if let Ok(c) = client { + if let Ok(resp) = c.get(HEALTH_URL).send() { + return resp.status().is_success(); + } + } + false +} + +fn which(cmd: &str) -> Option { + if let Ok(paths) = std::env::var("PATH") { + for p in std::env::split_paths(&paths) { + let full = p.join(cmd); + if full.exists() { + return Some(full); + } + } + } + // Check common podman-compose location + let home = std::env::var("HOME").unwrap_or_default(); + let local = PathBuf::from(home).join(".local/bin").join(cmd); + if local.exists() { + return Some(local); + } + None +} + +fn images_exist() -> bool { + // Check if required images are already present + if let Ok(out) = Command::new("podman") + .args(["images", "--format", "{{.Repository}}:{{.Tag}}"]) + .output() + { + let s = String::from_utf8_lossy(&out.stdout); + // Need at least one backend variant + pgvector + redis + let has_backend = s.contains("wheretf-backend"); + let has_pgvector = s.contains("pgvector"); + let has_redis = s.contains("redis"); + if has_backend && has_pgvector && has_redis { + return true; + } + println!( + "[backend] images_exist check: backend={}, pgvector={}, redis={}", + has_backend, has_pgvector, has_redis + ); + } + false +} + +fn find_images_tar(backend_dir: &Path) -> Option { + let mut candidates: Vec = Vec::new(); + // Direct in backend dir (dev + offline bundle) + candidates.push(backend_dir.join("backend-images.tar")); + candidates.push(backend_dir.join("backend-images.tar.gz")); + // Cargo manifest + candidates.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("WhereTF-backend/backend-images.tar")); + candidates.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("WhereTF-backend/backend-images.tar.gz")); + candidates.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("backend-images.tar")); + // Next to exe + if let Ok(exe) = std::env::current_exe() { + if let Some(parent) = exe.parent() { + candidates.push(parent.join("backend-images.tar")); + candidates.push(parent.join("backend-images.tar.gz")); + candidates.push(parent.join("WhereTF-backend/backend-images.tar")); + candidates.push(parent.join("resources/backend-images.tar")); + candidates.push(parent.join("resources/backend-images.tar.gz")); + if let Some(gp) = parent.parent() { + candidates.push(gp.join("backend-images.tar")); + candidates.push(gp.join("WhereTF-backend/backend-images.tar")); + } + } + } + // Cwd + if let Ok(cwd) = std::env::current_dir() { + candidates.push(cwd.join("WhereTF-backend/backend-images.tar")); + candidates.push(cwd.join("backend-images.tar")); + } + candidates.push(PathBuf::from("backend-images.tar")); + candidates.push(PathBuf::from("WhereTF-backend/backend-images.tar")); + + for c in candidates { + if c.exists() { + return Some(c); + } + } + None +} + +fn ensure_images_loaded(backend_dir: &Path) { + if images_exist() { + println!("[backend] images already present, skipping load"); + return; + } + println!("[backend] images missing, probing for bundled offline tar..."); + let Some(tar) = find_images_tar(backend_dir) else { + println!("[backend] no bundled tar found, will rely on podman-compose pull/build (requires internet)"); + return; + }; + println!( + "[backend] found bundled tar at {:?} ({} bytes), loading via podman load (may take 30-60s for 1.9GB)...", + tar, + tar.metadata().map(|m| m.len()).unwrap_or(0) + ); + // podman load handles both .tar and .tar.gz + let out = Command::new("podman") + .args(["load", "-i"]) + .arg(&tar) + .output(); + match out { + Ok(o) => { + println!("[backend] podman load stdout: {}", String::from_utf8_lossy(&o.stdout)); + let stderr = String::from_utf8_lossy(&o.stderr); + if !stderr.is_empty() { + eprintln!("[backend] podman load stderr: {}", stderr); + } + if o.status.success() { + println!("[backend] offline images loaded successfully"); + } else { + eprintln!("[backend] podman load failed with status {:?}", o.status); + } + } + Err(e) => eprintln!("[backend] failed to spawn podman load: {}", e), + } +} + +fn run_compose_up(backend_dir: &Path) -> Result<(), String> { + // Try order: podman-compose -> podman compose -> docker-compose -> docker compose + let attempts: Vec> = vec![ + vec!["podman-compose".into(), "up".into(), "-d".into()], + vec!["podman".into(), "compose".into(), "up".into(), "-d".into()], + vec!["docker-compose".into(), "up".into(), "-d".into()], + vec!["docker".into(), "compose".into(), "up".into(), "-d".into()], + ]; + + let mut last_err = String::new(); + for args in attempts { + let bin = &args[0]; + // For "podman compose" style, bin is podman/compose, need to check podman exists + let check_bin = if bin == "podman" && args.len() > 1 && args[1] == "compose" { + "podman" + } else { + bin.as_str() + }; + if which(check_bin).is_none() && Command::new(check_bin).arg("--version").output().is_err() { + last_err = format!("{} not found", check_bin); + continue; + } + + let mut cmd = Command::new(&args[0]); + for a in &args[1..] { + cmd.arg(a); + } + cmd.current_dir(backend_dir); + // For podman rootless, ensure env + println!("[backend] trying: {:?} in {:?}", args, backend_dir); + match cmd.output() { + Ok(out) => { + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + println!("[backend] stdout: {}", stdout); + if !stderr.is_empty() { + eprintln!("[backend] stderr: {}", stderr); + } + if out.status.success() { + return Ok(()); + } else { + last_err = format!("{:?} failed: {}", args, stderr); + // Try next + } + } + Err(e) => { + last_err = format!("failed to spawn {:?}: {}", args, e); + } + } + } + Err(last_err) +} + +/// Blocking version to call from main() before dioxus launch. +/// Spawns a thread so it doesn't block UI. +pub fn ensure_backend_blocking_spawn() { + std::thread::spawn(|| { + if is_healthy_blocking() { + println!("[backend] already healthy at {}", HEALTH_URL); + return; + } + println!("[backend] not healthy, attempting to start..."); + let Some(dir) = find_backend_dir() else { + eprintln!("[backend] could not find WhereTF-backend directory (probed CARGO_MANIFEST_DIR, exe parent, cwd)"); + return; + }; + println!("[backend] found backend dir: {:?}", dir); + // For fully offline single file: load bundled tar if images missing + ensure_images_loaded(&dir); + if let Err(e) = run_compose_up(&dir) { + eprintln!("[backend] compose up failed: {}", e); + return; + } + // Poll health + for i in 0..STARTUP_TIMEOUT_SECS { + std::thread::sleep(Duration::from_millis(POLL_INTERVAL_MS)); + if is_healthy_blocking() { + println!("[backend] healthy after {}s", i + 1); + return; + } + if i % 10 == 0 { + println!("[backend] waiting for health... {}s", i + 1); + } + } + eprintln!("[backend] timed out waiting for health at {}", HEALTH_URL); + }); +} + +pub async fn ensure_backend_async() -> Result<(), String> { + if is_healthy().await { + return Ok(()); + } + let Some(dir) = find_backend_dir() else { + return Err("WhereTF-backend directory not found".into()); + }; + ensure_images_loaded(&dir); + run_compose_up(&dir)?; + // Poll async + for i in 0..STARTUP_TIMEOUT_SECS { + tokio::time::sleep(Duration::from_millis(POLL_INTERVAL_MS)).await; + if is_healthy().await { + println!("[backend] async healthy after {}s", i + 1); + return Ok(()); + } + } + Err(format!("timed out waiting for {}", HEALTH_URL)) +} diff --git a/src/components/files_panel.css b/src/components/files_panel.css new file mode 100644 index 0000000..7547941 --- /dev/null +++ b/src/components/files_panel.css @@ -0,0 +1,49 @@ +.files-panel { + padding-top: 48px; + max-width: 520px; +} + +.panel-upload-btn { + font-family: var(--font-mono); + font-size: 14px; + color: var(--accent); + cursor: pointer; + margin-bottom: 28px; + transition: opacity 0.15s; +} +.panel-upload-btn:hover { + opacity: 0.8; +} + +.panel-empty { + font-family: var(--font-mono); + font-size: 14px; + color: var(--text-secondary); +} + +.panel-file-row { + display: flex; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid rgba(119, 114, 103, 0.2); + cursor: pointer; + transition: border-color 0.15s; + border-color: var(--accent-dim); +} + +.panel-file-name { + font-family: var(--font-mono); + font-size: 14px; + color: var(--text-secondary); + flex: 1; + word-break: break-all; +} + +.panel-file-type { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-secondary); + flex-shrink: 0; + margin-left: 20px; + opacity: 0.6; +} diff --git a/src/components/files_panel.rs b/src/components/files_panel.rs new file mode 100644 index 0000000..6aeefa2 --- /dev/null +++ b/src/components/files_panel.rs @@ -0,0 +1,45 @@ +use crate::api::IndexedFile; +use dioxus::prelude::*; +use std::path::Path; + +#[derive(Props, Clone, PartialEq)] +pub struct FilesPanelProps { + pub files: Vec, + pub uploading: bool, + pub upload_trigger: Signal, + pub on_open_file: EventHandler, +} + +#[component] +pub fn FilesPanel(props: FilesPanelProps) -> Element { + rsx! { + div { class: "files-panel", + div { class: "panel-heading", "Indexed Files" } + div { + class: "panel-upload-btn", + onclick: { + let mut ut = props.upload_trigger.clone(); + move |_| { let v = *ut.peek(); ut.set(v + 1); } + }, + if props.uploading { "Uploading..." } else { "+ add files" } + } + if props.files.is_empty() { + div { class: "panel-empty", "No files indexed yet." } + } else { + for file in &props.files { + div { + class: "panel-file-row", + key: "{file.id}", + onclick: { + let path = file.file_path.clone(); + let handler = props.on_open_file.clone(); + move |_| handler.call(path.clone()) + }, + div { class: "panel-file-name", "{file.file_path}" } + div { class: "panel-file-type", "{file.mime_type}" } + } + } + } + } + } +} diff --git a/src/components/filters_panel.css b/src/components/filters_panel.css new file mode 100644 index 0000000..6826559 --- /dev/null +++ b/src/components/filters_panel.css @@ -0,0 +1,68 @@ +.filters-panel { + padding-top: 48px; + max-width: 520px; +} + +.panel-heading { + font-family: var(--font-serif); + font-size: 24px; + color: var(--text-primary); + margin-bottom: 28px; +} + +.filter-options { + display: flex; + flex-direction: column; + gap: 2px; +} + +.filter-option { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 16px; + cursor: pointer; + border-left: 2px solid transparent; + transition: border-color 0.15s, background 0.15s; +} + +.filter-option:hover { + background: rgba(185, 85, 54, 0.04); +} + +.filter-option.selected { + border-left-color: var(--accent); + background: rgba(185, 85, 54, 0.06); +} + +.filter-radio { + width: 14px; + height: 14px; + border: 1px solid var(--border-muted); + border-radius: 50%; + flex-shrink: 0; + transition: border-color 0.15s, background 0.15s; +} + +.filter-option.selected .filter-radio { + border-color: var(--accent); + background: var(--accent); +} + +.filter-label { + display: flex; + flex-direction: column; + gap: 3px; +} + +.filter-name { + font-family: var(--font-mono); + font-size: 15px; + color: var(--text-primary); +} + +.filter-desc { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-secondary); +} diff --git a/src/components/filters_panel.rs b/src/components/filters_panel.rs new file mode 100644 index 0000000..4af843b --- /dev/null +++ b/src/components/filters_panel.rs @@ -0,0 +1,112 @@ +use dioxus::prelude::*; + +#[derive(Props, Clone, PartialEq)] +pub struct FiltersPanelProps { + pub mode: Signal, + pub power_mode: Signal, + pub spotlight_shortcut: Signal, + pub on_spotlight_shortcut_change: EventHandler, +} + +#[component] +pub fn FiltersPanel(props: FiltersPanelProps) -> Element { + rsx! { + div { class: "filters-panel", + div { class: "panel-heading", "Search Mode" } + div { class: "filter-options", + for (label, value) in [ + ("Hybrid — vector + keyword", "hybrid"), + ("Vector — semantic similarity", "vector"), + ("Keyword — full-text match", "keyword"), + ] { + label { + class: if props.mode.read().to_string() == value { "filter-option selected" } else { "filter-option" }, + onclick: { + let mut m = props.mode.clone(); + let v = value.to_string(); + move |_| m.set(v.clone()) + }, + div { class: "filter-radio" } + div { class: "filter-label", + div { class: "filter-name", "{label.split('\u{2014}').next().unwrap_or(label)}" } + div { class: "filter-desc", "{label.split('\u{2014}').nth(1).unwrap_or(\"\").trim()}" } + } + } + } + } + div { class: "power-toggle-section", + div { class: "panel-heading", style: "margin-top: 24px;", "Power Search" } + div { class: "power-toggle-row", + span { class: "power-toggle-label", "HyDE Expansion" } + button { + class: if *props.power_mode.read() { "power-toggle-btn active" } else { "power-toggle-btn" }, + onclick: { + let mut pm = props.power_mode.clone(); + move |_| { + let cur = *pm.read(); + pm.set(!cur); + } + }, + if *props.power_mode.read() { "ON" } else { "OFF" } + } + } + div { class: "power-toggle-desc", style: "font-size: 11px; opacity: 0.6; margin-top: 4px;", + if *props.power_mode.read() { "Uses WordNet HyDE query expansion (slower, more recall)" } else { "Direct search without expansion (fast)" } + } + } + div { class: "power-toggle-section", + div { class: "panel-heading", style: "margin-top: 24px;", "Spotlight" } + div { class: "power-toggle-row", + span { class: "power-toggle-label", "Shortcut" } + SpotlightShortcutInput { + shortcut: props.spotlight_shortcut, + on_change: props.on_spotlight_shortcut_change.clone(), + } + } + div { class: "power-toggle-desc", style: "font-size: 11px; opacity: 0.6; margin-top: 4px;", + "Global hotkey to focus search from anywhere. Click to change, then press new combo." + } + } + } + } +} + +#[component] +fn SpotlightShortcutInput(shortcut: Signal, on_change: EventHandler) -> Element { + let mut capturing = use_signal(|| false); + let display = if *capturing.read() { + "Press shortcut...".to_string() + } else { + shortcut.read().clone() + }; + rsx! { + button { + class: if *capturing.read() { "power-toggle-btn active" } else { "power-toggle-btn" }, + style: "min-width: 80px;", + onclick: move |_| { + capturing.set(true); + }, + onkeydown: move |e: KeyboardEvent| { + if !*capturing.read() { return; } + let code = e.code().to_string(); + // Ignore pure modifier presses + if code == "ControlLeft" || code == "ControlRight" || code == "AltLeft" + || code == "AltRight" || code == "ShiftLeft" || code == "ShiftRight" + || code == "MetaLeft" || code == "MetaRight" { + return; + } + let mods = e.modifiers(); + let ctrl = mods.ctrl(); + let alt = mods.alt(); + let shift = mods.shift(); + let meta = mods.meta(); + if let Some(new_shortcut) = crate::spotlight::event_to_shortcut_string(ctrl, alt, shift, meta, &code) { + on_change.call(new_shortcut); + } + capturing.set(false); + }, + onblur: move |_| capturing.set(false), + "{display}" + } + } +} diff --git a/src/components/header.css b/src/components/header.css new file mode 100644 index 0000000..68a6e1c --- /dev/null +++ b/src/components/header.css @@ -0,0 +1,23 @@ +.header { + display: flex; + justify-content: space-between; + align-items: baseline; + flex-shrink: 0; + padding: 36px 100px 0 100px; +} + +.header-logo { + font-family: var(--font-serif); + font-size: 32px; + font-weight: 500; + color: var(--text-primary); + letter-spacing: -0.5px; +} + +.header-view { + font-family: var(--font-mono); + font-size: 15px; + text-transform: uppercase; + letter-spacing: 3px; + color: var(--text-secondary); +} diff --git a/src/components/header.rs b/src/components/header.rs new file mode 100644 index 0000000..64c7066 --- /dev/null +++ b/src/components/header.rs @@ -0,0 +1,16 @@ +use dioxus::prelude::*; + +#[derive(Props, Clone, PartialEq)] +pub struct HeaderProps { + pub view_label: String, +} + +#[component] +pub fn Header(props: HeaderProps) -> Element { + rsx! { + div { class: "header", + div { class: "header-logo", "whereTF" } + div { class: "header-view", "{props.view_label}" } + } + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index 3a7a2d9..581f203 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,2 +1,9 @@ -pub mod sidebar; -pub mod search; +pub mod header; +pub mod step_rail; +pub mod search_bar; +pub mod search_screen; +pub mod results_screen; +pub mod filters_panel; +pub mod files_panel; +pub mod watchdog_panel; +pub mod spotlight_window; diff --git a/src/components/results_screen.css b/src/components/results_screen.css new file mode 100644 index 0000000..f4e287a --- /dev/null +++ b/src/components/results_screen.css @@ -0,0 +1,84 @@ +.results-screen { + padding-top: 0; +} + +.results-time { + font-family: var(--font-mono); + font-size: 13px; + color: var(--text-secondary); + text-align: right; + margin-top: 4px; + margin-bottom: 28px; +} + +.primary-result { + margin-bottom: 48px; +} + +.primary-result-heading { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 10px; +} + +.primary-result-label { + font-family: var(--font-serif); + font-size: 24px; + color: var(--text-primary); +} + +.primary-result-score { + font-family: var(--font-mono); + font-size: 28px; + color: var(--accent); +} + +.primary-result-path { + font-family: var(--font-mono); + font-size: 14px; + color: var(--text-secondary); + line-height: 1.5; +} + +.section-divider { + border: none; + border-top: 1px solid var(--border-muted); + margin: 0 0 32px 0; +} + +.related-heading { + font-family: var(--font-mono); + font-size: 14px; + text-transform: uppercase; + letter-spacing: 3px; + color: var(--text-secondary); + margin-bottom: 20px; +} + +.result-row { + display: flex; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid rgba(119, 114, 103, 0.2); + cursor: pointer; + transition: border-color 0.15s; +} + +.result-row:hover { + border-color: var(--accent-dim); +} + +.result-row-name { + font-family: var(--font-mono); + font-size: 14px; + color: var(--text-secondary); + flex: 1; +} + +.result-row-score { + font-family: var(--font-mono); + font-size: 14px; + color: var(--text-secondary); + flex-shrink: 0; +} diff --git a/src/components/results_screen.rs b/src/components/results_screen.rs new file mode 100644 index 0000000..775308e --- /dev/null +++ b/src/components/results_screen.rs @@ -0,0 +1,84 @@ +use crate::api::SearchResult; +use dioxus::prelude::*; +use std::path::Path; + +#[derive(Props, Clone, PartialEq)] +pub struct ResultsScreenProps { + pub query: String, + pub search_time: f64, + pub results: Vec, + pub on_open_file: EventHandler, + pub on_search: Signal, +} + +#[component] +pub fn ResultsScreen(props: ResultsScreenProps) -> Element { + let unique: Vec<&SearchResult> = { + let mut seen = std::collections::HashSet::new(); + props.results.iter().filter(|r| seen.insert(&r.file_path)).collect() + }; + let best = unique.first().copied(); + let related: &[&SearchResult] = if unique.len() > 1 { + &unique[1..] + } else { + &[] + }; + + rsx! { + div { class: "results-screen", + super::search_bar::SearchBar { + on_search: props.on_search, + placeholder: props.query.clone(), + } + div { class: "results-time", "{props.search_time:.2}s" } + + if let Some(best) = best { + div { class: "primary-result", + div { class: "primary-result-heading", + div { class: "primary-result-label", "Most relevant file" } + div { class: "primary-result-score", "{score_fmt(best.score)}%" } + } + div { + class: "primary-result-path", + onclick: { + let path = best.file_path.clone(); + let h = props.on_open_file.clone(); + move |_| h.call(path.clone()) + }, + "{best.file_path}" + } + } + } + + if !related.is_empty() { + hr { class: "section-divider" } + div { class: "related-heading", "RELATED \u{2014} {related.len()}" } + + for r in related { + div { + class: "result-row", + onclick: { + let path = r.file_path.clone(); + let h = props.on_open_file.clone(); + move |_| h.call(path.clone()) + }, + div { class: "result-row-name", "{file_name(&r.file_path)}" } + div { class: "result-row-score", "{score_fmt(r.score)}%" } + } + } + } + } + } +} + +fn score_fmt(score: f64) -> String { + format!("{:.0}", (score * 100.0).min(99.0).max(1.0)) +} + +fn file_name(path: &str) -> String { + Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(path) + .to_string() +} diff --git a/src/components/search.css b/src/components/search.css deleted file mode 100644 index fd63e71..0000000 --- a/src/components/search.css +++ /dev/null @@ -1,112 +0,0 @@ -.main-content { - width: 100%; - max-width: 640px; - display: flex; - flex-direction: column; - align-items: center; -} - -.search-wrapper { - display: flex; - justify-content: center; - width: 100%; -} -.search-bar { - display: flex; - align-items: center; - width: 100%; - max-width: 560px; - height: 52px; - padding: 6px 8px 6px 24px; - background: rgba(226, 165, 138, 0.15); - backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); - border: 1px solid rgba(226, 165, 138, 0.3); - border-radius: 28px; - transition: all 0.3s ease; - box-shadow: - 0 8px 32px rgba(226, 165, 138, 0.1), - inset 0 1px 1px rgba(255, 255, 255, 0.08); -} -.search-bar:focus-within { - background: rgba(226, 165, 138, 0.22); - border-color: rgba(226, 165, 138, 0.5); - box-shadow: - 0 8px 40px rgba(226, 165, 138, 0.18), - inset 0 1px 1px rgba(255, 255, 255, 0.12); -} -.search-input { - flex: 1; - background: transparent; - border: none; - outline: none; - font-size: 16px; - color: #fff; -} -.search-input::placeholder { color: rgba(226, 165, 138, 0.5); } - -.search-btn { - width: 40px; - height: 40px; - border: none; - background: transparent; - border-radius: 50%; - cursor: pointer; - flex-shrink: 0; - transition: background 0.2s; - padding: 0; - display: flex; - align-items: center; - justify-content: center; -} -.search-btn:hover { background: rgba(226, 165, 138, 0.18); } -.arrow-icon { width: 35px; height: 35px; } - -.search-results { - width: 100%; - max-width: 560px; - margin-top: 16px; - display: flex; - flex-direction: column; - gap: 10px; - max-height: calc(100vh - 140px); - overflow-y: auto; -} - -.result-item { - padding: 14px 18px; - border-radius: 12px; - background: rgba(226, 165, 138, 0.08); - border: 1px solid rgba(226, 165, 138, 0.12); - transition: all 0.2s; -} -.result-item:hover { - background: rgba(226, 165, 138, 0.14); - border-color: rgba(226, 165, 138, 0.25); -} - -.result-path { - font-size: 11px; - color: rgba(226, 165, 138, 0.6); - margin-bottom: 4px; - word-break: break-all; -} - -.result-content { - font-size: 14px; - color: #e0e0e0; - line-height: 1.5; - margin-bottom: 6px; -} - -.result-score { - font-size: 11px; - color: rgba(226, 165, 138, 0.5); -} - -.result-empty { - text-align: center; - padding: 24px; - font-size: 14px; - color: rgba(255, 255, 255, 0.35); -} diff --git a/src/components/search.rs b/src/components/search.rs deleted file mode 100644 index f7149f8..0000000 --- a/src/components/search.rs +++ /dev/null @@ -1,66 +0,0 @@ -use crate::api::SearchResult; -use dioxus::prelude::*; - -const ARROW: Asset = asset!("/assets/arrow.svg"); - -#[derive(Props, Clone, PartialEq)] -pub struct SearchBarProps { - pub on_search: EventHandler, - pub search_results: Vec, -} - -#[component] -pub fn SearchBar(props: SearchBarProps) -> Element { - let mut query = use_signal(String::new); - - let do_search = { - let query = query.clone(); - let on_search = props.on_search.clone(); - move || { - let q = query.read().clone(); - if !q.trim().is_empty() { - on_search.call(q); - } - } - }; - - rsx! { - div { class: "main-content", - div { class: "search-wrapper", - div { class: "search-bar", - input { - class: "search-input", - r#type: "text", - placeholder: "Search your documents...", - value: "{query}", - oninput: move |e| query.set(e.value()), - onkeydown: { - let do_search = do_search.clone(); - move |e| { - if e.key() == Key::Enter { - do_search(); - } - } - }, - } - button { - class: "search-btn", - onclick: move |_| do_search(), - img { class: "arrow-icon", src: "{ARROW}" } - } - } - } - if !props.search_results.is_empty() { - div { class: "search-results", - for result in &props.search_results { - div { class: "result-item", - div { class: "result-path", "{result.file_path}" } - div { class: "result-content", "{result.content}" } - div { class: "result-score", "Score: {result.score:.4}" } - } - } - } - } - } - } -} diff --git a/src/components/search_bar.css b/src/components/search_bar.css new file mode 100644 index 0000000..2576cad --- /dev/null +++ b/src/components/search_bar.css @@ -0,0 +1,34 @@ +.search-input-row { + display: flex; + align-items: center; + border-bottom: 1px solid var(--border-muted); + padding-bottom: 10px; + transition: border-color 0.15s; +} + +.search-input-row:focus-within { + border-color: var(--accent); +} + +.search-field { + flex: 1; + background: transparent; + border: none; + outline: none; + font-family: var(--font-mono); + font-size: 17px; + color: var(--text-primary); + caret-color: var(--accent); +} + +.search-field::placeholder { + color: var(--text-secondary); +} + +.shortcut-hint { + font-family: var(--font-mono); + font-size: 14px; + color: var(--text-secondary); + flex-shrink: 0; + margin-left: 16px; +} diff --git a/src/components/search_bar.rs b/src/components/search_bar.rs new file mode 100644 index 0000000..6fbd279 --- /dev/null +++ b/src/components/search_bar.rs @@ -0,0 +1,40 @@ +use dioxus::prelude::*; + +#[derive(Props, Clone, PartialEq)] +pub struct SearchBarProps { + pub on_search: Signal, + pub placeholder: String, +} + +#[component] +pub fn SearchBar(props: SearchBarProps) -> Element { + let mut query = use_signal(String::new); + + let mut submit = { + let mut os = props.on_search.clone(); + let query = query.clone(); + move || { + let q = query.read().clone(); + if !q.trim().is_empty() { + os.set(q); + } + } + }; + + rsx! { + div { class: "search-input-row", + input { + class: "search-field", + r#type: "text", + placeholder: "{props.placeholder}", + value: "{query}", + autofocus: true, + oninput: move |e| query.set(e.value()), + onkeydown: move |e| { + if e.key() == Key::Enter { submit(); } + }, + } + span { class: "shortcut-hint", "\u{2318}K" } + } + } +} diff --git a/src/components/search_screen.css b/src/components/search_screen.css new file mode 100644 index 0000000..671c9e0 --- /dev/null +++ b/src/components/search_screen.css @@ -0,0 +1,72 @@ +.search-screen { + display: flex; + flex-direction: column; + padding-top: 60px; +} + +.search-heading { + font-family: var(--font-serif); + font-size: 50px; + font-weight: 400; + color: var(--text-primary); + margin-bottom: 32px; + line-height: 1.1; +} + +.search-stats { + display: flex; + gap: 16px; + padding-top: 40px; + font-family: var(--font-mono); + font-size: 14px; + text-transform: uppercase; + letter-spacing: 2px; + color: var(--text-secondary); +} + +.upload-link { + cursor: pointer; + color: var(--accent); + transition: opacity 0.15s; +} +.upload-link:hover { + opacity: 0.8; +} +.power-toggle-row { + display: flex; + align-items: center; + gap: 12px; + margin-top: 16px; + font-family: var(--font-mono); +} +.power-toggle-label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-secondary); +} +.power-toggle-btn { + font-family: var(--font-mono); + font-size: 11px; + padding: 4px 10px; + border: 1px solid rgba(119, 114, 103, 0.3); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + border-radius: 12px; + min-width: 40px; + transition: all 0.15s; +} +.power-toggle-btn.active { + background: var(--accent); + border-color: var(--accent); + color: white; +} +.power-toggle-desc { + font-size: 11px; + color: var(--text-secondary); + opacity: 0.7; +} +.power-toggle-section { + margin-top: 16px; +} diff --git a/src/components/search_screen.rs b/src/components/search_screen.rs new file mode 100644 index 0000000..de016de --- /dev/null +++ b/src/components/search_screen.rs @@ -0,0 +1,54 @@ +use dioxus::prelude::*; + +#[derive(Props, Clone, PartialEq)] +pub struct SearchScreenProps { + pub on_search: Signal, + pub file_count: u64, + pub avg_time: f64, + pub upload_trigger: Signal, + pub power_mode: Signal, +} + +#[component] +pub fn SearchScreen(props: SearchScreenProps) -> Element { + rsx! { + div { class: "search-screen", + div { class: "search-heading", "Search your files." } + super::search_bar::SearchBar { + on_search: props.on_search.clone(), + placeholder: "type to search".to_string(), + } + div { class: "search-stats", + span { "{props.file_count} FILES INDEXED" } + span { "\u{00B7}" } + span { "{props.avg_time:.2}s AVG" } + span { "\u{00B7}" } + span { + class: "upload-link", + onclick: { + let mut ut = props.upload_trigger.clone(); + move |_| { let v = *ut.peek(); ut.set(v + 1); } + }, + "+ add files" + } + } + div { class: "power-toggle-row", + span { class: "power-toggle-label", "Power Search" } + button { + class: if *props.power_mode.read() { "power-toggle-btn active" } else { "power-toggle-btn" }, + onclick: { + let mut pm = props.power_mode.clone(); + move |_| { + let cur = *pm.read(); + pm.set(!cur); + } + }, + if *props.power_mode.read() { "ON" } else { "OFF" } + } + span { class: "power-toggle-desc", + if *props.power_mode.read() { "HyDE + WordNet expansion" } else { "Direct search" } + } + } + } + } +} diff --git a/src/components/sidebar.css b/src/components/sidebar.css deleted file mode 100644 index 829fd7e..0000000 --- a/src/components/sidebar.css +++ /dev/null @@ -1,153 +0,0 @@ -.sidebar { - width: 72px; - display: flex; - flex-direction: column; - align-items: center; - flex-shrink: 0; - position: relative; -} -.sidebar-logo { - width: 48px; - height: 40px; - margin-top: 24px; -} -.sidebar-buttons { - display: flex; - flex-direction: column; - align-items: center; - gap: 20px; - margin: auto 0; -} -.sidebar-icon-btn { - width: 36px; - height: 36px; - border: none; - background: transparent; - cursor: pointer; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - transition: transform 0.2s, opacity 0.2s; - opacity: 0.7; -} -.sidebar-icon-btn:hover { - transform: scale(1.1); - opacity: 1; -} -.sidebar-icon-btn.active { opacity: 1; } -.sidebar-icon { width: 36px; height: 36px; } -.sidebar-placeholder { - width: 20px; - height: 20px; - border-radius: 50%; - background: rgba(226, 165, 138, 0.2); -} - -.files-panel { - position: absolute; - left: 72px; - top: 0; - width: 260px; - height: 100vh; - background: rgba(15, 15, 20, 0.95); - backdrop-filter: blur(16px); - border-right: 1px solid rgba(226, 165, 138, 0.2); - padding: 20px; - overflow-y: auto; - z-index: 10; -} -.files-panel-header { - font-size: 14px; - font-weight: 600; - color: rgba(226, 165, 138, 0.9); - margin-bottom: 16px; - text-transform: uppercase; - letter-spacing: 1px; -} -.files-empty { - font-size: 13px; - color: rgba(255, 255, 255, 0.4); - text-align: center; - padding: 24px 0; -} -.file-item { - padding: 12px; - margin-bottom: 8px; - border-radius: 8px; - background: rgba(226, 165, 138, 0.06); - border: 1px solid rgba(226, 165, 138, 0.08); - cursor: pointer; - transition: all 0.2s; -} -.file-item:hover { - background: rgba(226, 165, 138, 0.12); - border-color: rgba(226, 165, 138, 0.2); -} -.file-name { - font-size: 12px; - color: #e0e0e0; - margin-bottom: 6px; - word-break: break-all; -} -.file-meta { - display: flex; - flex-wrap: wrap; - gap: 4px; -} -.file-context { - font-size: 11px; - color: rgba(255, 255, 255, 0.5); -} -.file-tag { - font-size: 10px; - padding: 2px 6px; - border-radius: 4px; - background: rgba(226, 165, 138, 0.15); - color: rgba(226, 165, 138, 0.8); -} - -.upload-btn { - width: 100%; - padding: 10px 14px; - margin-bottom: 14px; - border: 1px dashed rgba(226, 165, 138, 0.3); - border-radius: 8px; - background: rgba(226, 165, 138, 0.06); - color: rgba(226, 165, 138, 0.8); - font-size: 13px; - cursor: pointer; - transition: all 0.2s; -} -.upload-btn:hover { - background: rgba(226, 165, 138, 0.14); - border-color: rgba(226, 165, 138, 0.5); -} -.upload-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.filter-options { - display: flex; - flex-direction: column; - gap: 6px; -} -.filter-option { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 12px; - border-radius: 8px; - background: rgba(226, 165, 138, 0.05); - cursor: pointer; - font-size: 13px; - color: #e0e0e0; - transition: all 0.15s; -} -.filter-option:hover { - background: rgba(226, 165, 138, 0.12); -} -.filter-option input[type="radio"] { - accent-color: rgba(226, 165, 138, 0.8); -} diff --git a/src/components/sidebar.rs b/src/components/sidebar.rs deleted file mode 100644 index fdfe66e..0000000 --- a/src/components/sidebar.rs +++ /dev/null @@ -1,134 +0,0 @@ -use crate::api::IndexedFile; -use dioxus::prelude::*; - -const LOGO: Asset = asset!("/assets/logo.svg"); -const FILTER: Asset = asset!("/assets/filter.svg"); -const FILE_ICON: Asset = asset!("/assets/file.svg"); - -#[derive(Props, Clone, PartialEq)] -pub struct SidebarProps { - pub files: Vec, - pub selected_file: Signal>, - pub on_upload: EventHandler<()>, - pub uploading: bool, - pub mode: Signal, -} - -#[component] -pub fn Sidebar(props: SidebarProps) -> Element { - let active_panel = use_signal(|| 0u8); - - rsx! { - div { class: "sidebar", - img { class: "sidebar-logo", src: "{LOGO}" } - div { class: "sidebar-buttons", - button { - class: if active_panel() == 1 { "sidebar-icon-btn active" } else { "sidebar-icon-btn" }, - onclick: { - let mut ap = active_panel.clone(); - move |_| ap.set(if ap() == 1 { 0 } else { 1 }) - }, - img { class: "sidebar-icon", src: "{FILTER}" } - } - button { - class: if active_panel() == 2 { "sidebar-icon-btn active" } else { "sidebar-icon-btn" }, - onclick: { - let mut ap = active_panel.clone(); - move |_| ap.set(if ap() == 2 { 0 } else { 2 }) - }, - img { class: "sidebar-icon", src: "{FILE_ICON}" } - } - button { class: "sidebar-icon-btn", - div { class: "sidebar-placeholder" } - } - button { class: "sidebar-icon-btn", - div { class: "sidebar-placeholder" } - } - } - if active_panel() == 1 { - div { class: "files-panel", - div { class: "files-panel-header", "Search Mode" } - div { class: "filter-options", - label { - class: "filter-option", - input { - r#type: "radio", - name: "search-mode", - value: "hybrid", - checked: props.mode.read().to_string() == "hybrid", - onchange: { - let mut mode = props.mode.clone(); - move |_| mode.set("hybrid".to_string()) - } - } - span { "Hybrid" } - } - label { - class: "filter-option", - input { - r#type: "radio", - name: "search-mode", - value: "vector", - checked: props.mode.read().to_string() == "vector", - onchange: { - let mut mode = props.mode.clone(); - move |_| mode.set("vector".to_string()) - } - } - span { "Vector" } - } - label { - class: "filter-option", - input { - r#type: "radio", - name: "search-mode", - value: "keyword", - checked: props.mode.read().to_string() == "keyword", - onchange: { - let mut mode = props.mode.clone(); - move |_| mode.set("keyword".to_string()) - } - } - span { "Keyword" } - } - } - } - } - if active_panel() == 2 { - div { class: "files-panel", - div { class: "files-panel-header", "Indexed Files" } - button { - class: "upload-btn", - onclick: move |_| props.on_upload.call(()), - disabled: props.uploading, - if props.uploading { "Uploading..." } else { "+ Add Files" } - } - if props.files.is_empty() { - div { class: "files-empty", "No files indexed yet." } - } else { - for file in &props.files { - div { - class: "file-item", - key: "{file.id}", - onclick: { - let id = file.id.clone(); - let mut selected = props.selected_file.clone(); - move |_| selected.set(Some(id.clone())) - }, - div { class: "file-name", "{file.file_path}" } - div { class: "file-meta", - if let Some(ref ctx) = file.context { - span { class: "file-context", "{ctx}" } - } - for tag in &file.tags { - span { class: "file-tag", "{tag}" } - } - } - } - } - } - } - } - } - } -} diff --git a/src/components/spotlight_window.css b/src/components/spotlight_window.css new file mode 100644 index 0000000..703c2e1 --- /dev/null +++ b/src/components/spotlight_window.css @@ -0,0 +1,90 @@ +html, body { + margin: 0; + padding: 0; + background: transparent !important; + overflow: hidden; +} +.sw-root { + width: 100vw; + height: 100vh; + background: transparent; + color: #ded8cc; + font-family: "JetBrains Mono", ui-monospace, monospace; + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + padding: 12px; + box-sizing: border-box; +} +.sw-pill { + display: flex; + align-items: center; + gap: 12px; + background: #1b1f1f; + border: 1px solid #2e3a3a; + border-radius: 999px; + padding: 12px 20px; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55); +} +.sw-icon { + color: #b95536; + font-size: 18px; + line-height: 1; + flex-shrink: 0; +} +.sw-input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: #ded8cc; + font-family: inherit; + font-size: 16px; + caret-color: #b95536; +} +.sw-input::placeholder { + color: #777267; +} +.sw-results { + margin-top: 8px; + background: #161a1a; + border: 1px solid #2e3a3a; + border-radius: 18px; + overflow: hidden; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5); +} +.sw-result { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 11px 20px; + cursor: pointer; + font-size: 13px; + border-bottom: 1px solid #222a2a; +} +.sw-result:last-child { + border-bottom: none; +} +.sw-result:hover { + background: #1f2626; +} +.sw-result-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + color: #ded8cc; +} +.sw-result-score { + color: #b95536; + font-size: 11px; + flex-shrink: 0; +} +.sw-empty { + padding: 20px 20px; + font-size: 12px; + color: #777267; + text-align: center; +} \ No newline at end of file diff --git a/src/components/spotlight_window.rs b/src/components/spotlight_window.rs new file mode 100644 index 0000000..1221d72 --- /dev/null +++ b/src/components/spotlight_window.rs @@ -0,0 +1,77 @@ +use crate::api; +use dioxus::prelude::*; +use std::time::Duration; + +/// Floating, always-on-top desktop search bar (macOS Spotlight-style). +/// A pill-shaped input with live results as you type. Runs in its own window. +#[component] +pub fn SpotlightWindow() -> Element { + let query = use_signal(String::new); + let results = use_signal(|| Vec::::new()); + let window = dioxus_desktop::use_window(); + + // Live search: runs whenever the query changes (debounced). + use_effect(move || { + let q = query.read().clone(); + let mut results = results.clone(); + if q.trim().is_empty() { + results.set(Vec::new()); + return; + } + // Debounce typing. + let qq = q.clone(); + spawn(async move { + tokio::time::sleep(Duration::from_millis(180)).await; + if let Ok(resp) = api::search(&qq, "hybrid", 8).await { + results.set(resp.results); + } + }); + }); + + rsx! { + div { class: "sw-root", + onkeydown: move |e: KeyboardEvent| { + if e.key() == Key::Escape { + window.set_visible(false); + } + }, + div { class: "sw-pill", + span { class: "sw-icon", "\u{2315}" } + input { + class: "sw-input", + r#type: "text", + placeholder: "Search your files\u{2026}", + autofocus: true, + value: "{query}", + oninput: { + let mut q = query.clone(); + move |e| q.set(e.value()) + }, + } + } + if !query.read().trim().is_empty() { + div { class: "sw-results", + if results.read().is_empty() { + div { class: "sw-empty", "No results" } + } else { + for r in results.read().iter() { + div { + class: "sw-result", + onclick: { + let path = r.file_path.clone(); + let window = window.clone(); + move |_| { + let _ = open::that(&path); + window.set_visible(false); + } + }, + span { class: "sw-result-name", "{r.file_path}" } + span { class: "sw-result-score", "{r.score:.2}" } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/src/components/step_rail.css b/src/components/step_rail.css new file mode 100644 index 0000000..abca956 --- /dev/null +++ b/src/components/step_rail.css @@ -0,0 +1,34 @@ +.step-rail { + display: flex; + flex-direction: column; + gap: 32px; + flex-shrink: 0; + padding-top: 4px; +} + +.step-square { + width: 24px; + height: 24px; + border: 1px solid var(--border-muted); + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.15s; +} + +.step-square.active { + border-color: var(--accent); +} + +.step-inner { + width: 8px; + height: 8px; + border: 1px solid var(--border-muted); + transition: border-color 0.15s; +} + +.step-square.active .step-inner { + border-color: var(--accent); +} diff --git a/src/components/step_rail.rs b/src/components/step_rail.rs new file mode 100644 index 0000000..c416a1e --- /dev/null +++ b/src/components/step_rail.rs @@ -0,0 +1,25 @@ +use dioxus::prelude::*; + +#[derive(Props, Clone, PartialEq)] +pub struct StepRailProps { + pub active_step: u8, + pub active_setter: Signal, +} + +#[component] +pub fn StepRail(props: StepRailProps) -> Element { + rsx! { + div { class: "step-rail", + for i in 1..=4u8 { + div { + class: if props.active_step == i { "step-square active" } else { "step-square" }, + onclick: { + let mut setter = props.active_setter.clone(); + move |_| setter.set(i) + }, + div { class: "step-inner" } + } + } + } + } +} diff --git a/src/components/watchdog_panel.css b/src/components/watchdog_panel.css new file mode 100644 index 0000000..e616583 --- /dev/null +++ b/src/components/watchdog_panel.css @@ -0,0 +1,70 @@ +.watchdog-panel { + padding-top: 48px; + max-width: 520px; +} + +.watchdog-row { + display: flex; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid rgba(119, 114, 103, 0.2); +} + +.watchdog-path { + font-family: var(--font-mono); + font-size: 14px; + color: var(--text-secondary); + flex: 1; + word-break: break-all; +} + +.watchdog-status { + font-family: var(--font-mono); + font-size: 12px; + color: var(--accent); + flex-shrink: 0; + margin-left: 20px; +} +.watchdog-status.paused { + color: #888; + opacity: 0.7; +} +.watchdog-actions { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 16px; + flex-wrap: wrap; +} +.watchdog-btn { + font-family: var(--font-mono); + font-size: 11px; + padding: 6px 10px; + border: 1px solid rgba(119, 114, 103, 0.3); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + border-radius: 4px; + transition: all 0.15s; +} +.watchdog-btn:hover { + border-color: var(--accent); + color: var(--accent); +} +.watchdog-btn-small { + font-size: 10px; + padding: 4px 8px; + margin-left: 8px; +} +.watchdog-btn-danger { + border-color: rgba(255, 107, 107, 0.3); + color: #ff6b6b; +} +.watchdog-btn-danger:hover { + border-color: #ff6b6b; + background: rgba(255, 107, 107, 0.1); +} +.watchdog-btn-pause { + border-color: rgba(255, 193, 7, 0.3); + color: #ffc107; +} diff --git a/src/components/watchdog_panel.rs b/src/components/watchdog_panel.rs new file mode 100644 index 0000000..b7f2c13 --- /dev/null +++ b/src/components/watchdog_panel.rs @@ -0,0 +1,86 @@ +use crate::api::WatchedFolder; +use dioxus::prelude::*; +use std::collections::HashSet; + +#[derive(Props, Clone, PartialEq)] +pub struct WatchdogPanelProps { + pub folders: Vec, + pub add_trigger: Signal, + pub on_remove: EventHandler, + pub on_toggle_pause: EventHandler, + pub paused: ReadSignal>, + pub on_pause_all: EventHandler<()>, + pub on_remove_all: EventHandler<()>, +} + +#[component] +pub fn WatchdogPanel(props: WatchdogPanelProps) -> Element { + let paused_set = props.paused.read().clone(); + let is_all_paused = !props.folders.is_empty() + && props.folders.iter().all(|f| paused_set.contains(&f.folder_path)); + let has_folders = !props.folders.is_empty(); + + rsx! { + div { class: "watchdog-panel", + div { class: "panel-heading", "Watched Folders" } + div { class: "watchdog-actions", + div { + class: "panel-upload-btn", + onclick: { + let mut at = props.add_trigger.clone(); + move |_| { let v = *at.peek(); at.set(v + 1); } + }, + "+ watch folder" + } + if has_folders { + button { + class: if is_all_paused { "watchdog-btn watchdog-btn-pause" } else { "watchdog-btn" }, + onclick: move |_| props.on_pause_all.call(()), + if is_all_paused { "Resume All" } else { "Pause All" } + } + button { + class: "watchdog-btn watchdog-btn-danger", + onclick: move |_| props.on_remove_all.call(()), + "Remove All" + } + } + } + if props.folders.is_empty() { + div { class: "panel-empty", "No folders watched." } + } else { + for folder in props.folders.iter() { + { + let is_paused = paused_set.contains(&folder.folder_path); + let folder_path = folder.folder_path.clone(); + rsx! { + div { class: "watchdog-row", + div { class: "watchdog-path", "{folder.folder_path}" } + div { class: if is_paused { "watchdog-status paused" } else { "watchdog-status" }, + if is_paused { "paused" } else { "active" } + } + button { + class: "watchdog-btn watchdog-btn-small", + onclick: { + let path = folder_path.clone(); + let handler = props.on_toggle_pause.clone(); + move |_| handler.call(path.clone()) + }, + if is_paused { "Resume" } else { "Pause" } + } + button { + class: "watchdog-btn watchdog-btn-small watchdog-btn-danger", + onclick: { + let path = folder_path.clone(); + let handler = props.on_remove.clone(); + move |_| handler.call(path.clone()) + }, + "Remove" + } + } + } + } + } + } + } + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..294155b --- /dev/null +++ b/src/config.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +const DEFAULT_SHORTCUT: &str = "Alt+K"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppConfig { + pub spotlight_shortcut: String, +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + spotlight_shortcut: DEFAULT_SHORTCUT.to_string(), + } + } +} + +fn config_path() -> Option { + dirs::config_dir().map(|p| p.join("wheretf").join("config.json")) +} + +pub fn load() -> AppConfig { + if let Some(path) = config_path() { + if path.exists() { + if let Ok(data) = std::fs::read_to_string(&path) { + if let Ok(cfg) = serde_json::from_str::(&data) { + return cfg; + } + } + } + } + AppConfig::default() +} + +pub fn save(cfg: &AppConfig) { + if let Some(path) = config_path() { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(data) = serde_json::to_string_pretty(cfg) { + let _ = std::fs::write(path, data); + } + } +} + +pub fn get_shortcut() -> String { + load().spotlight_shortcut +} + +pub fn set_shortcut(shortcut: &str) { + let mut cfg = load(); + cfg.spotlight_shortcut = shortcut.to_string(); + save(&cfg); +} diff --git a/src/main.rs b/src/main.rs index f6a2477..9246231 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,38 @@ mod api; +#[cfg(feature = "bundled-backend")] +mod backend; +mod config; +mod portal_shortcut; +mod spotlight; +mod watcher; mod components; -use components::{sidebar::Sidebar, search::SearchBar}; +use std::collections::{HashMap, HashSet}; +use std::time::Instant; +use components::{header::Header, step_rail::StepRail, search_screen::SearchScreen, results_screen::ResultsScreen, filters_panel::FiltersPanel, files_panel::FilesPanel, watchdog_panel::WatchdogPanel}; use dioxus::prelude::*; - -const BG: Asset = asset!("/assets/bg.png"); +use dioxus_desktop::WindowCloseBehaviour; fn main() { - let global_css = include_str!("style.css"); - let sidebar_css = include_str!("components/sidebar.css"); - let search_css = include_str!("components/search.css"); - let bg_url = format!("background-image: url('{BG}'); background-color: #0f0f14; background-size: cover; background-position: center;"); + // Auto-start backend only if bundled-backend feature is enabled. + // For split mode (default), frontend is standalone and expects + // the backend service to be running separately via `podman-compose up -d` + // in WhereTF-backend/. Use `cargo run --features bundled-backend` for combined. + #[cfg(feature = "bundled-backend")] + backend::ensure_backend_blocking_spawn(); let css = format!( - "" + "", + include_str!("style.css"), + include_str!("components/header.css"), + include_str!("components/step_rail.css"), + include_str!("components/search_bar.css"), + include_str!("components/search_screen.css"), + include_str!("components/results_screen.css"), + include_str!("components/filters_panel.css"), + include_str!("components/files_panel.css"), + include_str!("components/watchdog_panel.css"), + format!("body {{ background: #050606; }}") ); dioxus::LaunchBuilder::new() @@ -21,87 +40,592 @@ fn main() { dioxus_desktop::Config::new() .with_custom_head(css) .with_menu(None) + .with_close_behaviour(WindowCloseBehaviour::WindowHides) .with_window( dioxus_desktop::WindowBuilder::new() - .with_title("WhereTF") + .with_title("whereTF") .with_theme(Some(dioxus_desktop::tao::window::Theme::Dark)) - .with_inner_size(dioxus_desktop::LogicalSize::new(960.0, 680.0)), + .with_inner_size(dioxus_desktop::LogicalSize::new(1100.0, 750.0)), ) }) .launch(app); } fn app() -> Element { - let mut files = use_signal(Vec::new); - let selected_file = use_signal(|| None::); + let files = use_signal(Vec::new); let search_results = use_signal(|| Vec::::new()); let mode = use_signal(|| "hybrid".to_string()); + let power_mode = use_signal(|| false); + let spotlight_shortcut = use_signal(|| config::get_shortcut()); + let mut spotlight_visible = use_signal(|| false); let uploading = use_signal(|| false); + let file_paths = use_signal(HashMap::::new); + let active_step = use_signal(|| 1u8); + let query_text = use_signal(String::new); + let search_time = use_signal(|| 0.0); + let file_count = use_signal(|| 0u64); + let search_query = use_signal(String::new); + let upload_trigger = use_signal(|| 0i32); + let watch_trigger = use_signal(|| 0i32); + let watch_folders = use_signal(Vec::::new); + let paused_folders = use_signal(|| std::collections::HashSet::::new()); + let backend_ready = use_signal(|| false); + let backend_error = use_signal(|| Option::::None); + + // Ensure backend is running. + // - With `bundled-backend` feature (combined app): auto-starts via podman-compose + // - Without (default, split): just checks health, shows "Backend not available" if down + #[cfg(feature = "bundled-backend")] + use_effect(move || { + let mut backend_ready = backend_ready.clone(); + let mut backend_error = backend_error.clone(); + spawn(async move { + if backend::is_healthy().await { + backend_ready.set(true); + return; + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + if backend::is_healthy().await { + backend_ready.set(true); + return; + } + match backend::ensure_backend_async().await { + Ok(_) => backend_ready.set(true), + Err(e) => backend_error.set(Some(e)), + } + }); + }); + #[cfg(not(feature = "bundled-backend"))] + use_effect(move || { + let mut backend_ready = backend_ready.clone(); + let mut backend_error = backend_error.clone(); + spawn(async move { + // Simple health check without auto-start (split mode) + // Poll for 5s, then show error if still not healthy + for _ in 0..5 { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(1)) + .build(); + if let Ok(c) = client { + if let Ok(resp) = c.get("http://127.0.0.1:8000/health").send().await { + if resp.status().is_success() { + backend_ready.set(true); + return; + } + } + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + // After 5s, still not healthy -> show message but keep polling in background + // Check once more synchronously via api + if let Ok(healthy) = reqwest::get("http://127.0.0.1:8000/health").await { + if healthy.status().is_success() { + backend_ready.set(true); + return; + } + } + backend_error.set(Some( + "Backend not running. Please start it with: cd WhereTF-backend && podman-compose up -d (or docker-compose up -d)".to_string(), + )); + // Keep polling every 5s in background to auto-recover when service starts + loop { + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(1)) + .build(); + if let Ok(c) = client { + if let Ok(resp) = c.get("http://127.0.0.1:8000/health").send().await { + if resp.status().is_success() { + backend_ready.set(true); + backend_error.set(None); + break; + } + } + } + } + }); + }); - let on_search = move |query: String| { - let mode = mode.clone(); + use_effect(move || { + let q = search_query.read().clone(); + let is_power = power_mode(); + if q.is_empty() { return; } + let mode = mode.read().clone(); let mut search_results = search_results.clone(); + let mut search_time = search_time.clone(); + let mut query_text = query_text.clone(); + query_text.set(q.clone()); spawn(async move { - match api::search(&query, &mode(), 10).await { - Ok(resp) => search_results.set(resp.results), + let start = Instant::now(); + let resp = if is_power { + api::power_search(&q, &mode, 10).await + } else { + api::search(&q, &mode, 10).await + }; + match resp { + Ok(resp) => { + search_time.set(start.elapsed().as_secs_f64()); + search_results.set(resp.results); + } Err(_) => {} } }); + }); + + use_effect(move || { + let count = upload_trigger(); + if count <= 0 { return; } + if uploading() { return; } + let mut uploading = uploading.clone(); + let mut files = files.clone(); + let mut file_count = file_count.clone(); + let mut file_paths = file_paths.clone(); + spawn(async move { + if let Some(path) = rfd::FileDialog::new().pick_file() { + uploading.set(true); + let path_str = path.to_string_lossy().to_string(); + let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string(); + file_paths.set({ + let mut m = file_paths.peek().clone(); + m.insert(filename, path_str.clone()); + m + }); + let _ = api::upload_file(&path_str).await; + for _ in 0..8 { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + if let Ok(f) = api::get_all_files().await { + file_count.set(f.len() as u64); + files.set(f.clone()); + if !f.is_empty() { break; } + } + } + uploading.set(false); + } + }); + }); + + use_effect(move || { + if !backend_ready() { return; } + let mut files = files.clone(); + let mut file_count = file_count.clone(); + let mut wf = watch_folders.clone(); + spawn(async move { + if let Ok(f) = api::get_all_files().await { + file_count.set(f.len() as u64); + files.set(f); + } + if let Ok(w) = api::get_watched_folders().await { + wf.set(w.clone()); + // Start local filesystem watchers for each watched folder (Rust notify) + for folder in w { + watcher::watch_folder(folder.folder_path); + } + } + }); + }); + + use_effect(move || { + let count = watch_trigger(); + if count <= 0 { return; } + let mut wf = watch_folders.clone(); + spawn(async move { + if let Some(path) = rfd::FileDialog::new().pick_folder() { + let p = path.to_string_lossy().to_string(); + match api::add_watch_folder(&p).await { + Ok(_) => { + // Start watching this folder locally (Rust notify) + watcher::watch_folder(p.clone()); + if let Ok(w) = api::get_watched_folders().await { + wf.set(w); + } + } + Err(e) => { + eprintln!("[watcher] add_watch_folder failed: {}", e); + } + } + } + }); + }); + + let on_open_file = use_callback(move |path: String| { + // If it's already an absolute path (contains / or \), open directly + // Otherwise, try to resolve via file_paths map (for legacy upload flow) + if path.contains('/') || path.contains('\\') { + if std::path::Path::new(&path).exists() { + let _ = open::that(&path); + } else { + // Fallback: try lookup by file_name + let fname = std::path::Path::new(&path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(&path) + .to_string(); + if let Some(p) = file_paths.read().get(&fname).cloned() { + let _ = open::that(&p); + } else { + // Last resort: try to open as-is (will show OS error if not found) + let _ = open::that(&path); + } + } + } else { + // It's a file_name, lookup in map + if let Some(p) = file_paths.read().get(&path).cloned() { + let _ = open::that(&p); + } + } + }); + + let on_remove_folder = { + let watch_folders = watch_folders.clone(); + let paused_folders = paused_folders.clone(); + use_callback(move |folder_path: String| { + let mut watch_folders = watch_folders.clone(); + let mut paused_folders = paused_folders.clone(); + let folder_path_clone = folder_path.clone(); + spawn(async move { + let _ = api::remove_watch_folder(&folder_path_clone).await; + watcher::stop_watcher(&folder_path_clone); + paused_folders.write().remove(&folder_path_clone); + if let Ok(w) = api::get_watched_folders().await { + watch_folders.set(w); + } + }); + }) }; - let on_upload = { - let files = files.clone(); - let uploading = uploading.clone(); - move |_| { - let mut files = files.clone(); - let mut uploading = uploading.clone(); + let on_toggle_pause = { + let paused_folders = paused_folders.clone(); + use_callback(move |folder_path: String| { + let mut paused_folders = paused_folders.clone(); + let is_paused = paused_folders.read().contains(&folder_path); + if is_paused { + watcher::watch_folder(folder_path.clone()); + paused_folders.write().remove(&folder_path); + } else { + watcher::stop_watcher(&folder_path); + paused_folders.write().insert(folder_path); + } + }) + }; + + let on_pause_all = { + let watch_folders = watch_folders.clone(); + let paused_folders = paused_folders.clone(); + use_callback(move |_: ()| { + let mut paused_folders = paused_folders.clone(); + let folders = watch_folders.read().clone(); + let is_all_paused = !folders.is_empty() + && folders.iter().all(|f| paused_folders.read().contains(&f.folder_path)); + if is_all_paused { + for f in &folders { + watcher::watch_folder(f.folder_path.clone()); + } + paused_folders.set(HashSet::new()); + } else { + for f in &folders { + watcher::stop_watcher(&f.folder_path); + } + let set: HashSet = folders.into_iter().map(|f| f.folder_path).collect(); + paused_folders.set(set); + } + }) + }; + + let on_remove_all = { + let watch_folders = watch_folders.clone(); + let paused_folders = paused_folders.clone(); + use_callback(move |_: ()| { + let mut watch_folders = watch_folders.clone(); + let mut paused_folders = paused_folders.clone(); spawn(async move { - if let Some(path) = rfd::FileDialog::new().pick_file() { - uploading.set(true); - let path_str = path.to_string_lossy().to_string(); - match api::upload_file(&path_str).await { - Ok(_) => { - for _ in 0..8 { - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - if let Ok(f) = api::get_all_files().await { - files.set(f.clone()); - if !f.is_empty() { - break; - } - } - } + let folders = watch_folders.read().clone(); + for f in folders { + let _ = api::remove_watch_folder(&f.folder_path).await; + watcher::stop_watcher(&f.folder_path); + } + paused_folders.set(HashSet::new()); + if let Ok(w) = api::get_watched_folders().await { + watch_folders.set(w); + } else { + watch_folders.set(Vec::new()); + } + }); + }) + }; + + let on_spotlight_shortcut_change = { + let spotlight_shortcut = spotlight_shortcut.clone(); + use_callback(move |new_shortcut: String| { + config::set_shortcut(&new_shortcut); + let mut sc = spotlight_shortcut.clone(); + sc.set(new_shortcut.clone()); + println!("[spotlight] shortcut changed to {}, restart required", new_shortcut); + }) + }; + + // Spotlight trigger channel. + // Multiple sources (X11 global hotkey, XDG portal for Wayland, tray menu, + // in-app key) all send `()` to this one Sender; a single UI-side poller + // toggles `spotlight_visible`. Created once via use_hook. + let spotlight_trigger = use_hook(move || { + let (tx, rx) = std::sync::mpsc::channel::<()>(); + + // UI-side poller: forward triggers to the spotlight_visible signal. + let mut spotlight_visible = spotlight_visible.clone(); + let mut active_step = active_step.clone(); + let mut search_query = search_query.clone(); + let mut search_results = search_results.clone(); + spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_millis(60)).await; + while rx.try_recv().is_ok() { + let cur = *spotlight_visible.read(); + let next = !cur; + if next { + // Opening spotlight: start fresh, just the searchbar + search_query.set(String::new()); + search_results.set(Vec::new()); + } + spotlight_visible.set(next); + active_step.set(1); + println!("[spotlight] toggled visible -> {}", next); + } + } + }); + + // #1 X11 global hotkey backend (works on X11 + non-GNOME Wayland/XWayland). + let shortcut = config::get_shortcut(); + let tx_x11 = tx.clone(); + std::thread::spawn(move || { + use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager}; + let Some(hotkey) = crate::spotlight::parse_shortcut(&shortcut) else { + eprintln!("[spotlight] invalid shortcut: {}", shortcut); + return; + }; + let manager = match GlobalHotKeyManager::new() { + Ok(m) => m, + Err(e) => { + eprintln!("[spotlight] X11 hotkey manager error: {:?}", e); + return; + } + }; + if let Err(e) = manager.register(hotkey) { + eprintln!( + "[spotlight] X11 global hotkey register failed ({}); GNOME Wayland needs \ + the portal fallback instead", + e + ); + return; + } + println!("[spotlight] X11 global hotkey registered: {}", shortcut); + let receiver = GlobalHotKeyEvent::receiver(); + loop { + if let Ok(event) = receiver.recv() { + if event.id == hotkey.id() { + let _ = tx_x11.send(()); + } + } + } + }); + + tx + }); + + // #2 XDG Desktop Portal (GlobalShortcuts) - the Wayland/GNOME path. + { + let tx = spotlight_trigger.clone(); + let shortcut = config::get_shortcut(); + spawn(async move { + match crate::portal_shortcut::bind(tx, shortcut, "open-spotlight").await { + Ok(_) => {} + Err(e) => eprintln!("[spotlight] XDG portal not available: {e}"), + } + }); + } + + // #3 Tray menu "Open Spotlight…" as a guaranteed path on every desktop. + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + { + use dioxus_desktop::trayicon; + let tx = spotlight_trigger.clone(); + let _tray = use_hook(move || { + use dioxus_desktop::trayicon::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem}; + use dioxus_desktop::trayicon::TrayIconBuilder; + let menu = Menu::new(); + let open_item = MenuItem::with_id("wheretf-open", "Open Spotlight…", true, None); + let _ = menu.append(&open_item); + let _ = menu.append(&PredefinedMenuItem::separator()); + let _ = menu.append(&PredefinedMenuItem::quit(None)); + let open_id = open_item.id().clone(); + let t = TrayIconBuilder::new() + .with_menu(Box::new(menu)) + .with_menu_on_left_click(false) + .with_tooltip("WhereTF") + .build() + .ok(); + // Drive tray clicks into the spotlight channel. + std::thread::spawn(move || { + let rx = MenuEvent::receiver(); + loop { + if let Ok(ev) = rx.recv() { + if ev.id == open_id { + let _ = tx.send(()); } - Err(_) => {} } - uploading.set(false); } }); + t + }); + } + + // In-window Alt+K fallback (works whenever the app window is focused, including + // on GNOME Wayland where a true global grab from a non-installed dev binary isn't + // permitted). Uses the same trigger channel as X11/portal/tray. + let spotlight_tx_inapp = spotlight_trigger.clone(); + let spotlight_inapp_handler = move |e: KeyboardEvent| { + if e.modifiers().alt() && e.code().to_string() == "KeyK" { + let _ = spotlight_tx_inapp.send(()); } }; + // Build the floating Spotlight window once (frameless, always-on-top, centered, + // hidden until shown). Store its handle so the show/hide effect can control it. + let desktop_window = dioxus_desktop::use_window(); + let spotlight_window = use_signal(|| None::); + { + let mut spotlight_window = spotlight_window.clone(); + use_effect(move || { + let desktop_window = desktop_window.clone(); + spawn(async move { + use dioxus_desktop::tao::dpi::{PhysicalPosition, PhysicalSize, Position}; + use dioxus_desktop::tao::window::WindowBuilder; + use dioxus_desktop::Config; + // Center it on the primary monitor. + let (w, h) = desktop_window + .current_monitor() + .map(|m| m.size().to_logical::(1.0)) + .map(|s| (s.width as i32, s.height as i32)) + .unwrap_or((1280, 800)); + let (sw, sh) = (1230, 420); // 1.7x wider pill, room for live results + let pos = Position::Physical(PhysicalPosition::new((w - sw) / 2, (h - sh) / 2)); + let css = format!( + "", + include_str!("components/spotlight_window.css") + ); + let wb = WindowBuilder::new() + .with_title("WhereTF Spotlight") + .with_inner_size(PhysicalSize::new(sw, sh)) + .with_position(pos) + .with_decorations(false) + .with_always_on_top(true) + .with_transparent(true) + .with_visible(false); + let cfg = Config::new().with_window(wb).with_custom_head(css); + let dom = dioxus_core::VirtualDom::new(components::spotlight_window::SpotlightWindow); + let win = dioxus_desktop::window().new_window(dom, cfg).await; + println!("[spotlight] floating window ready"); + spotlight_window.set(Some(win)); + }); + }); + } + + // Show/hide the floating spotlight window in response to triggers. + let mut spotlight_window = spotlight_window.clone(); use_effect(move || { - spawn(async move { - if let Ok(f) = api::get_all_files().await { - files.set(f); + let v = spotlight_visible(); + if let Some(win) = spotlight_window.read().as_ref() { + if v { + win.set_always_on_top(true); + win.set_visible(true); + win.set_focus(); + println!("[spotlight] floating window shown"); + } else { + win.set_visible(false); } - }); + } }); + let view_label = match active_step() { + 1 => "/ 01 \u{00B7} SEARCH".to_string(), + 2 => "/ 02 \u{00B7} FILTERS".to_string(), + 3 => "/ 03 \u{00B7} FILES".to_string(), + 4 => "/ 04 \u{00B7} WATCH".to_string(), + _ => "/ \u{00B7}".to_string(), + }; + rsx! { - div { class: "app-layout", - Sidebar { - files: files.read().clone(), - selected_file: selected_file.clone(), - on_upload: on_upload, - uploading: uploading(), - mode: mode.clone(), - } - div { class: "main-area", - SearchBar { - on_search: on_search, - search_results: search_results.read().clone(), + // The main app window always renders normally. Spotlight is a separate + // floating window (ctrl + top spotlight_window handle) shown/hidden above. + div { id: "main", + onkeydown: spotlight_inapp_handler, + div { class: "app-shell", + Header { view_label: view_label } + if !backend_ready() { + div { class: "backend-loading", style: "padding: 48px; font-family: var(--font-mono); color: var(--text-secondary);", + if let Some(err) = backend_error.read().as_ref() { + div { style: "color: #ff6b6b; margin-bottom: 12px;", "Backend failed to start:" } + div { style: "font-size: 12px; word-break: break-all;", "{err}" } + div { style: "margin-top: 16px; font-size: 12px;", "Ensure podman and podman-compose are installed and WhereTF-backend/docker-compose.yml exists." } + div { style: "margin-top: 12px; font-size: 12px;", "You can also run manually: cd WhereTF-backend && podman-compose up -d" } + } else { + div { "Starting backend — db + api + redis + worker" } + div { style: "margin-top: 8px; font-size: 12px; opacity: 0.7;", "First launch may take 60-90s (Jina CLIP model download)..." } + } + } + } else { + div { class: "app-body", + StepRail { + active_step: active_step(), + active_setter: active_step.clone(), + } + div { class: "app-content", + if active_step() == 1 { + if search_results.read().is_empty() { + SearchScreen { + on_search: search_query.clone(), + file_count: file_count(), + avg_time: 0.04, + upload_trigger: upload_trigger.clone(), + power_mode: power_mode, + } + } else { + ResultsScreen { + query: query_text.read().clone(), + search_time: search_time(), + results: search_results.read().clone(), + on_open_file: on_open_file.clone(), + on_search: search_query.clone(), + } + } + } else if active_step() == 2 { + FiltersPanel { + mode: mode.clone(), + power_mode: power_mode, + spotlight_shortcut: spotlight_shortcut, + on_spotlight_shortcut_change: on_spotlight_shortcut_change.clone(), + } + } else if active_step() == 3 { + FilesPanel { + files: files.read().clone(), + uploading: uploading(), + upload_trigger: upload_trigger.clone(), + on_open_file: on_open_file.clone(), + } + } else if active_step() == 4 { + WatchdogPanel { + folders: watch_folders.read().clone(), + add_trigger: watch_trigger.clone(), + on_remove: on_remove_folder.clone(), + on_toggle_pause: on_toggle_pause.clone(), + paused: paused_folders, + on_pause_all: on_pause_all.clone(), + on_remove_all: on_remove_all.clone(), + } + } + } } } + } } } } diff --git a/src/portal_shortcut.rs b/src/portal_shortcut.rs new file mode 100644 index 0000000..c82cb35 --- /dev/null +++ b/src/portal_shortcut.rs @@ -0,0 +1,52 @@ +//! XDG Desktop Portal global shortcut (works on Wayland, e.g. GNOME). +//! Falls back gracefully if the portal is unavailable. + +use std::sync::mpsc::Sender; +use futures::StreamExt; + +pub async fn bind( + tx: Sender<()>, + shortcut: String, + id: &'static str, +) -> Result<(), String> { + use ashpd::desktop::global_shortcuts::{GlobalShortcuts, NewShortcut}; + + let shortcuts = GlobalShortcuts::new() + .await + .map_err(|e| format!("portal connect failed: {e}"))?; + + let session = shortcuts + .create_session() + .await + .map_err(|e| format!("create_session failed: {e}"))?; + + let ns = NewShortcut::new(id, "Open WhereTF Spotlight".to_string()) + .preferred_trigger(Some(shortcut.as_str())); + let bound_req = shortcuts + .bind_shortcuts(&session, std::slice::from_ref(&ns), None) + .await + .map_err(|e| format!("bind_shortcuts failed: {e}"))?; + let bound = bound_req + .response() + .map_err(|e| format!("bind response failed: {e}"))?; + for sc in bound.shortcuts() { + println!( + "[spotlight] portal bound '{}' ({}): {}", + sc.id(), + sc.description(), + sc.trigger_description() + ); + } + + let mut activated = shortcuts + .receive_activated() + .await + .map_err(|e| format!("receive_activated failed: {e}"))?; + println!("[spotlight] portal listener ready for global shortcut '{shortcut}'"); + while let Some(ev) = activated.next().await { + if ev.shortcut_id() == id { + let _ = tx.send(()); + } + } + Ok(()) +} \ No newline at end of file diff --git a/src/spotlight.rs b/src/spotlight.rs new file mode 100644 index 0000000..2ac1f3f --- /dev/null +++ b/src/spotlight.rs @@ -0,0 +1,151 @@ +use global_hotkey::hotkey::{Code, HotKey, Modifiers}; +use std::str::FromStr; + +/// Normalize a display key name ("K", "7", "F1") to the physical-code name +/// the `keyboard_types` `Code::from_str` expects ("KeyK", "Digit7", "F1"). +fn normalize_code_name(s: &str) -> String { + let trimmed = s.trim(); + if trimmed.is_empty() { + return trimmed.to_string(); + } + // Single letter -> Key + if trimmed.len() == 1 { + let c = trimmed.chars().next().unwrap(); + if c.is_ascii_alphabetic() { + return format!("Key{}", c.to_ascii_uppercase()); + } + if c.is_ascii_digit() { + return format!("Digit{}", c); + } + } + // If it already looks like a physical code ("KeyK", "Digit7", "Space", "F1"), pass through + if trimmed.starts_with("Key") + || trimmed.starts_with("Digit") + || trimmed.starts_with("F") && trimmed.len() > 1 + || trimmed == "Space" + || trimmed == "Enter" + || trimmed == "Tab" + || trimmed == "Escape" + { + return trimmed.to_string(); + } + // Otherwise try uppercase single-letter style ("K" -> "KeyK") + trimmed.to_ascii_uppercase() +} + +pub fn parse_shortcut(s: &str) -> Option { + // Format: "Alt+K", "Ctrl+Shift+P", "Cmd+K", "Ctrl+Shift+KeyP", etc. + let parts: Vec<&str> = s.split('+').map(|p| p.trim()).collect(); + if parts.is_empty() { + return None; + } + let mut mods = Modifiers::empty(); + let mut code_str = ""; + for part in parts { + let lower = part.to_lowercase(); + match lower.as_str() { + "alt" | "option" => mods |= Modifiers::ALT, + "ctrl" | "control" => mods |= Modifiers::CONTROL, + "shift" => mods |= Modifiers::SHIFT, + "super" | "cmd" | "command" | "win" | "meta" => mods |= Modifiers::SUPER, + _ => code_str = part, + } + } + if code_str.is_empty() { + return None; + } + // Try to parse Code from string like "K", "A", "F1", "Space" -> "KeyK", "KeyA", "F1", "Space" + let code_name = normalize_code_name(code_str); + let code = Code::from_str(&code_name).ok()?; + HotKey::new(Some(mods), code).into() +} + +pub fn shortcut_to_string(hotkey: &HotKey) -> String { + let mut parts: Vec = Vec::new(); + let mods = hotkey.mods; + if mods.contains(Modifiers::CONTROL) { + parts.push("Ctrl".to_string()); + } + if mods.contains(Modifiers::ALT) { + parts.push("Alt".to_string()); + } + if mods.contains(Modifiers::SHIFT) { + parts.push("Shift".to_string()); + } + if mods.contains(Modifiers::SUPER) { + #[cfg(target_os = "macos")] + parts.push("Cmd".to_string()); + #[cfg(target_os = "windows")] + parts.push("Win".to_string()); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + parts.push("Super".to_string()); + } + let code_str = format!("{:?}", hotkey.key); + let display = if code_str.starts_with("Key") && code_str.len() == 4 { + code_str[3..].to_string() + } else if code_str.starts_with("Digit") && code_str.len() == 6 { + code_str[5..].to_string() + } else { + code_str + }; + parts.push(display); + parts.join("+") +} + +// For capturing a new shortcut from a keyboard event in the UI. +// `code` should be the physical key code from the event (e.g. dioxus `e.code()` +// returns "KeyK", "Digit7", "F1"). Modifiers come from `e.modifiers()`. +pub fn event_to_shortcut_string(ctrl: bool, alt: bool, shift: bool, meta: bool, code: &str) -> Option { + let trimmed = code.trim(); + if trimmed.is_empty() + || trimmed == "Control" + || trimmed == "Alt" + || trimmed == "Shift" + || trimmed == "Meta" + { + return None; + } + let mut parts: Vec = Vec::new(); + if ctrl { parts.push("Ctrl".to_string()); } + if alt { parts.push("Alt".to_string()); } + if shift { parts.push("Shift".to_string()); } + if meta { + #[cfg(target_os = "macos")] + parts.push("Cmd".to_string()); + #[cfg(not(target_os = "macos"))] + parts.push("Super".to_string()); + } + // Store the compact, human-friendly form ("K", "7", "F1") rather than the + // internal physical-code name ("KeyK", "Digit7"). parse_shortcut() accepts both. + let compact = compact_code(trimmed); + parts.push(compact); + Some(parts.join("+")) +} + +/// Convert a physical code name ("KeyK", "Digit7", "F1") to a compact display name. +pub fn compact_code(s: &str) -> String { + let t = s.trim(); + if let Some(rest) = t.strip_prefix("Key") { + if rest.len() == 1 && rest.chars().next().unwrap().is_ascii_alphabetic() { + return rest.to_uppercase(); + } + return t.to_string(); + } + if let Some(rest) = t.strip_prefix("Digit") { + if rest.len() == 1 { + return rest.to_string(); + } + } + t.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_parse() { + assert!(parse_shortcut("Alt+K").is_some()); + assert!(parse_shortcut("Ctrl+Shift+P").is_some()); + assert!(parse_shortcut("Cmd+K").is_some()); + } +} diff --git a/src/style.css b/src/style.css index 70431f6..0d1a8ff 100644 --- a/src/style.css +++ b/src/style.css @@ -1,17 +1,122 @@ +:root { + --bg-outer: #1c2020; + --bg-inner: #050606; + --text-primary: #ded8cc; + --text-secondary: #9b968b; + --border-muted: #777267; + --accent: #b95536; + --accent-dim: rgba(185, 85, 54, 0.5); + --font-serif: Georgia, "Instrument Serif", "Cormorant Garamond", serif; + --font-mono: "JetBrains Mono", "IBM Plex Mono", "Fira Code", "Cascadia Code", "Courier New", monospace; +} + * { margin: 0; padding: 0; box-sizing: border-box; } + body { - color: #e0e0e0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg-outer); + color: var(--text-primary); + font-family: var(--font-mono); min-height: 100vh; -} -.app-layout { display: flex; + align-items: center; + justify-content: center; +} + +#main { + width: 100vw; height: 100vh; } -.main-area { + +.app-shell { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.app-body { + flex: 1; + display: flex; + padding: 32px 100px 32px 100px; + overflow: hidden; +} + +.app-content { flex: 1; + padding-left: 60px; + overflow-y: auto; +} +#spotlight-root { + width: 100vw; + height: 100vh; + background: transparent; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 20vh; +} +.spotlight-keysink { + position: fixed; + inset: 0; + outline: none; +} +.spotlight-box { + background: #1c2020; + border: 1px solid rgba(119, 114, 103, 0.3); + border-radius: 12px; + width: 600px; + max-width: 90vw; + padding: 20px; + box-shadow: 0 20px 60px rgba(0,0,0,0.5); +} +.spotlight-header { + font-family: var(--font-mono); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-secondary); + margin-bottom: 12px; + opacity: 0.7; +} +.spotlight-results { + margin-top: 16px; display: flex; flex-direction: column; + gap: 4px; +} +.spotlight-result-row { + display: flex; + justify-content: space-between; align-items: center; - padding-top: 48px; + padding: 8px 12px; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s; +} +.spotlight-result-row:hover { + background: rgba(185, 85, 54, 0.1); +} +.spotlight-result-name { + font-family: var(--font-mono); + font-size: 13px; + color: var(--text-primary); + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.spotlight-result-score { + font-family: var(--font-mono); + font-size: 11px; + color: var(--accent); + margin-left: 12px; +} +.spotlight-hint { + margin-top: 12px; + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); + opacity: 0.5; + text-align: center; } diff --git a/src/watcher.rs b/src/watcher.rs new file mode 100644 index 0000000..d1ca882 --- /dev/null +++ b/src/watcher.rs @@ -0,0 +1,306 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use notify::{Event, EventKind, RecursiveMode, Watcher}; +use walkdir::WalkDir; + +use crate::api; + +static WATCHERS: std::sync::OnceLock>>> = + std::sync::OnceLock::new(); + +fn watchers_map() -> Arc>> { + WATCHERS + .get_or_init(|| Arc::new(Mutex::new(HashMap::new()))) + .clone() +} + +fn debounce_map() -> Arc>> { + static DEBOUNCE: std::sync::OnceLock>>> = + std::sync::OnceLock::new(); + DEBOUNCE + .get_or_init(|| Arc::new(Mutex::new(HashMap::new()))) + .clone() +} + +const DEBOUNCE_SECS: f64 = 1.5; + +fn should_process(path: &Path) -> bool { + let now = Instant::now(); + let debounce = debounce_map(); + let mut map = debounce.lock().unwrap(); + if let Some(last) = map.get(path) { + if now.duration_since(*last).as_secs_f64() < DEBOUNCE_SECS { + return false; + } + } + map.insert(path.to_path_buf(), now); + // Clean old entries occasionally + if map.len() > 1000 { + map.retain(|_, t| now.duration_since(*t).as_secs_f64() < 10.0); + } + true +} + +pub fn calculate_file_hash(path: &Path) -> Result { + use sha2::{Digest, Sha256}; + use std::fs::File; + use std::io::Read; + let mut file = File::open(path).map_err(|e| format!("open failed: {}", e))?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 8192]; + loop { + let n = file.read(&mut buf).map_err(|e| format!("read failed: {}", e))?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(hex::encode(hasher.finalize())) +} + +async fn handle_created(path: &Path) { + if !should_process(path) { + return; + } + // Wait a bit for file to be fully written (like python's time.sleep(1)) + tokio::time::sleep(Duration::from_secs(1)).await; + if !path.exists() || path.is_dir() { + return; + } + println!("[watcher] Created: {:?}", path); + let p = path.to_string_lossy().to_string(); + match api::upload_file(&p).await { + Ok(r) => println!("[watcher] upload ok: {:?}", r), + Err(e) => eprintln!("[watcher] upload failed for {:?}: {}", path, e), + } +} + +async fn handle_modified(path: &Path) { + if !should_process(path) { + return; + } + if !path.exists() || path.is_dir() { + return; + } + println!("[watcher] Modified: {:?}", path); + // For modify, the python does delete+upload (modify). We'll try that. + let p = path.to_string_lossy().to_string(); + // First try to get hash and check needs_indexing to avoid redundant upload + if let Ok(hash) = calculate_file_hash(path) { + if let Ok(needs) = api::needs_indexing(&p, &hash).await { + if !needs { + println!("[watcher] Skipping modified (no change): {:?}", path); + return; + } + } + } + // Use upload (which will create or update). The backend's process_file_task handles + // existing file_path by setting state=processing and re-indexing. + match api::upload_file(&p).await { + Ok(r) => println!("[watcher] re-index ok: {:?}", r), + Err(e) => eprintln!("[watcher] re-index failed for {:?}: {}", path, e), + } +} + +async fn handle_deleted(path: &Path) { + // Don't debounce deletes - they are distinct + println!("[watcher] Deleted: {:?}", path); + let p = path.to_string_lossy().to_string(); + match api::delete_file_by_path(&p).await { + Ok(r) => println!("[watcher] delete ok: {:?}", r), + Err(e) => eprintln!("[watcher] delete failed for {:?}: {}", path, e), + } +} + +async fn handle_moved(from: &Path, to: &Path) { + println!("[watcher] Moved: {:?} -> {:?}", from, to); + let old = from.to_string_lossy().to_string(); + let new = to.to_string_lossy().to_string(); + match api::rename_file(&old, &new).await { + Ok(r) => println!("[watcher] rename ok: {:?}", r), + Err(e) => { + eprintln!("[watcher] rename failed: {}, trying upload+delete", e); + // Fallback: upload new, delete old + let _ = api::upload_file(&new).await; + let _ = api::delete_file_by_path(&old).await; + } + } +} + +pub async fn scan_folder(folder_path: &str) { + let path = Path::new(folder_path); + if !path.exists() { + eprintln!("[watcher] scan_folder: not found {:?}", folder_path); + return; + } + println!("[watcher] Scanning folder: {}", folder_path); + for entry in WalkDir::new(path).into_iter().filter_map(|e| e.ok()) { + if !entry.file_type().is_file() { + continue; + } + let file_path = entry.path(); + let p = file_path.to_string_lossy().to_string(); + match calculate_file_hash(file_path) { + Ok(hash) => match api::needs_indexing(&p, &hash).await { + Ok(true) => { + println!("[watcher] Indexing: {}", p); + if let Err(e) = api::upload_file(&p).await { + eprintln!("[watcher] Failed to index {}: {}", p, e); + } + } + Ok(false) => { + // Skipping + } + Err(e) => eprintln!("[watcher] needs_indexing check failed for {}: {}", p, e), + }, + Err(e) => eprintln!("[watcher] hash failed for {}: {}", p, e), + } + // Small yield to avoid blocking + tokio::task::yield_now().await; + } + println!("[watcher] Scan complete for {}", folder_path); +} + +pub fn watch_folder(folder_path: String) { + let path = PathBuf::from(&folder_path); + if !path.exists() { + eprintln!("[watcher] watch_folder: path does not exist: {}", folder_path); + return; + } + // Avoid duplicate watchers + { + let watchers = watchers_map(); + let map = watchers.lock().unwrap(); + if map.contains_key(&folder_path) { + println!("[watcher] Already watching {}", folder_path); + return; + } + } + + println!("[watcher] Starting watcher for {}", folder_path); + + // Spawn a thread for the watcher (notify is sync, needs thread) + let folder_clone = folder_path.clone(); + std::thread::spawn(move || { + // Setup watcher FIRST so events are not missed during initial scan + let (tx, rx) = std::sync::mpsc::channel(); + let mut watcher: notify::RecommendedWatcher = + notify::Watcher::new(tx, notify::Config::default()).unwrap(); + watcher + .watch(Path::new(&folder_clone), RecursiveMode::Recursive) + .unwrap(); + + // Store watcher so it isn't dropped + { + let watchers = watchers_map(); + let mut map = watchers.lock().unwrap(); + map.insert(folder_clone.clone(), watcher); + } + + println!("[watcher] Watching {} (recursive)", folder_clone); + + // Initial scan in background (don't block watcher) + let folder_clone2 = folder_clone.clone(); + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + scan_folder(&folder_clone2).await; + }); + }); + + // Event loop + for res in rx { + match res { + Ok(event) => { + // Spawn async handling for each event + let event_clone = event.clone(); + // Use a new runtime for async api calls + // We can't easily spawn tokio from sync thread, so use block_on with new runtime + // Instead, we spawn via tokio::task if runtime exists, but we're in sync thread. + // So we handle by spawning a temporary runtime + let rt2 = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt2.block_on(async { + handle_event(event_clone).await; + }); + } + Err(e) => eprintln!("[watcher] watch error: {:?}", e), + } + } + }); +} + +async fn handle_event(event: Event) { + match event.kind { + EventKind::Create(_) => { + for path in event.paths { + handle_created(&path).await; + } + } + EventKind::Modify(_) => { + // Distinguish rename vs modify via event paths length? + // notify reports Modify for content changes and Move for renames + for path in event.paths { + handle_modified(&path).await; + } + } + EventKind::Remove(_) => { + for path in event.paths { + handle_deleted(&path).await; + } + } + _ => { + // Handle Move (rename) which is a Modify with 2 paths or specific kind + // notify 6.1 uses EventKind::Modify with history? Let's check for Any + // For safety, check if event is a Move (has 2 paths) + if event.paths.len() == 2 { + // Likely a rename + handle_moved(&event.paths[0], &event.paths[1]).await; + } else { + for path in event.paths { + // Fallback to modified + handle_modified(&path).await; + } + } + } + } +} + +pub fn stop_watcher(folder_path: &str) { + let watchers = watchers_map(); + let mut map = watchers.lock().unwrap(); + if let Some(watcher) = map.remove(folder_path) { + drop(watcher); + println!("[watcher] Stopped watching {}", folder_path); + } +} + +pub fn start_watchers_for_existing() { + // Called on startup to watch all folders already in DB + std::thread::spawn(|| { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + match api::get_watched_folders().await { + Ok(folders) => { + for f in folders { + watch_folder(f.folder_path); + // Small delay to avoid thundering herd + tokio::time::sleep(Duration::from_millis(200)).await; + } + } + Err(e) => eprintln!("[watcher] failed to fetch watched folders on startup: {}", e), + } + }); + }); +}