From 4f909ccc90622bf76a28a980a1b7bc00e4fd37e3 Mon Sep 17 00:00:00 2001 From: Jakob Stender Guldberg Date: Fri, 21 Aug 2026 20:32:37 +0200 Subject: [PATCH 1/2] build: cut compile times via dev-profile debuginfo, git2 0.20, rustls - dev profile: line-tables-only for workspace code, no debuginfo for dependencies and build scripts - git2 0.19 -> 0.20 as a workspace dependency with default-features off: only local git operations are used, so the ssh/https transports, libssh2-sys, and vendored openssl leave the build graph - diffcore-core reqwest: rustls-tls-native-roots (system trust store kept) instead of native-tls; tauri dev-dep reqwest likewise, so cargo test no longer needs system OpenSSL headers Full clean dev build 177.6s -> ~125-135s with plain cargo; 61.1s +/- 0.7s (hyperfine, n=3) with the mold+sccache devShell. 2191 tests pass. --- Cargo.lock | 190 ++++++++++++++++++++++++------- Cargo.toml | 13 +++ crates/diffcore-cli/Cargo.toml | 2 +- crates/diffcore-core/Cargo.toml | 4 +- crates/diffcore-tauri/Cargo.toml | 4 +- 5 files changed, 166 insertions(+), 47 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 65a5c915..1e11dc8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -647,6 +647,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -862,6 +879,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1959,8 +1985,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1982,10 +2010,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -2032,16 +2063,14 @@ dependencies = [ [[package]] name = "git2" -version = "0.19.0" +version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b903b73e45dc0c6c596f2d37eccece7c1c8bb6e4407b001096387c63d0d93724" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" dependencies = [ "bitflags 2.11.0", "libc", "libgit2-sys", "log", - "openssl-probe 0.1.6", - "openssl-sys", "url", ] @@ -2363,6 +2392,7 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls", @@ -2939,15 +2969,13 @@ dependencies = [ [[package]] name = "libgit2-sys" -version = "0.17.0+1.8.1" +version = "0.18.7+1.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10472326a8a6477c3c20a64547b0059e4b0d086869eee31e6d7da728a8eb7224" +checksum = "23c7391e4b9f4ffab1a624223cc1d7385ff9a678f490768add717de7ea2f4d89" dependencies = [ "cc", "libc", - "libssh2-sys", "libz-sys", - "openssl-sys", "pkg-config", ] @@ -2973,20 +3001,6 @@ dependencies = [ "redox_syscall 0.7.3", ] -[[package]] -name = "libssh2-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" -dependencies = [ - "cc", - "libc", - "libz-sys", - "openssl-sys", - "pkg-config", - "vcpkg", -] - [[package]] name = "libz-sys" version = "1.1.25" @@ -3035,6 +3049,12 @@ dependencies = [ "imgref", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac" version = "0.1.1" @@ -3241,7 +3261,7 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe 0.2.1", + "openssl-probe", "openssl-sys", "schannel", "security-framework", @@ -3713,27 +3733,12 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-src" -version = "300.5.5+3.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" -dependencies = [ - "cc", -] - [[package]] name = "openssl-sys" version = "0.9.112" @@ -3742,7 +3747,6 @@ checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" dependencies = [ "cc", "libc", - "openssl-src", "pkg-config", "vcpkg", ] @@ -4401,6 +4405,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand 0.10.2", + "rand_pcg 0.10.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -4433,7 +4493,7 @@ dependencies = [ "rand_chacha 0.2.2", "rand_core 0.5.1", "rand_hc", - "rand_pcg", + "rand_pcg 0.2.1", ] [[package]] @@ -4457,6 +4517,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -4514,6 +4585,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_hc" version = "0.2.0" @@ -4532,6 +4609,15 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -4737,6 +4823,9 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -4744,6 +4833,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -4863,7 +4953,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe 0.2.1", + "openssl-probe", "rustls-pki-types", "schannel", "security-framework", @@ -4875,6 +4965,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -5283,7 +5374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -6127,6 +6218,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokenizers" version = "0.21.4" diff --git a/Cargo.toml b/Cargo.toml index 96821105..001f9f55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ serde = { version = "1", features = ["derive"] } # stable for arbitrary f64 scores (see assert_json_roundtrip). serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" +git2 = { version = "0.20", default-features = false } # Optimize test builds: trades slightly longer compile for faster test runtime. # tree-sitter parsing and query execution benefit significantly from opt-level 1. @@ -21,3 +22,15 @@ log = "0.4" # Tests are isolated via TempDirs, safe to fully parallelize. [profile.test] opt-level = 1 + +# Dev-build compile time: workspace code keeps line tables for usable +# backtraces; the ~750 dependency crates and build scripts skip debuginfo +# entirely (nobody steps through tauri/tokio internals in a debugger). +[profile.dev] +debug = "line-tables-only" + +[profile.dev.package."*"] +debug = false + +[profile.dev.build-override] +debug = false diff --git a/crates/diffcore-cli/Cargo.toml b/crates/diffcore-cli/Cargo.toml index 02f66873..9fa9c722 100644 --- a/crates/diffcore-cli/Cargo.toml +++ b/crates/diffcore-cli/Cargo.toml @@ -11,7 +11,7 @@ path = "src/main.rs" diffcore-core = { path = "../diffcore-core" } clap = { version = "4", features = ["derive"] } serde_json = { workspace = true } -git2 = "0.19" +git2 = { workspace = true } tokio = { version = "1", features = ["full"] } tempfile = "3" log = { workspace = true } diff --git a/crates/diffcore-core/Cargo.toml b/crates/diffcore-core/Cargo.toml index 410a3a7c..cc5bb7ab 100644 --- a/crates/diffcore-core/Cargo.toml +++ b/crates/diffcore-core/Cargo.toml @@ -7,7 +7,7 @@ edition.workspace = true serde = { workspace = true } serde_json = { workspace = true } petgraph = "0.6" -git2 = { version = "0.19", features = ["vendored-openssl"] } +git2 = { workspace = true } thiserror = "2" tree-sitter = "0.26.7" tree-sitter-typescript = "0.23.2" @@ -17,7 +17,7 @@ tree-sitter-rust = "0.24.1" tree-sitter-java = "0.23.5" toml = "0.8" glob = "0.3" -reqwest = { version = "0.12", features = ["json"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "http2", "charset", "rustls-tls-native-roots", "macos-system-configuration"] } tokio = { version = "1", features = ["full"] } async-trait = "0.1" sha2 = "0.10" diff --git a/crates/diffcore-tauri/Cargo.toml b/crates/diffcore-tauri/Cargo.toml index 62e3491f..2fb35830 100644 --- a/crates/diffcore-tauri/Cargo.toml +++ b/crates/diffcore-tauri/Cargo.toml @@ -14,7 +14,7 @@ tauri-plugin-updater = "2" tauri-plugin-process = "2" serde = { workspace = true } serde_json = { workspace = true } -git2 = { version = "0.19", features = ["vendored-openssl"] } +git2 = { workspace = true } tokio = { version = "1", features = ["full"] } thiserror = "2" log = { workspace = true } @@ -29,7 +29,7 @@ futures-core = "0.3" [dev-dependencies] tempfile = "3" serde_json = { workspace = true } -reqwest = "0.12" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots"] } [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" From 4c91e695c64e97473eed6a62419fa3755c878215 Mon Sep 17 00:00:00 2001 From: Jakob Stender Guldberg Date: Sat, 22 Aug 2026 09:37:12 +0200 Subject: [PATCH 2/2] feat(web): host the tauri UI as a browser web app via diffcore-web New headless axum binary serves the built UI and mirrors the tauri IPC surface over HTTP so diffs can be reviewed on a remote server: - commands decoupled from tauri: cfg-swapped State shim, process-global background runtime replacing tauri::async_runtime, cfg-gated watchers; features 'desktop' (default) and 'web' are mutually exclusive - POST /api/invoke/{cmd} (camelCase args, 1:1 with tauri IPC), GET /api/health, same-origin SSE job streams, SPA static serving - Host/Origin allowlist guard (DNS-rebinding/CSRF) + strict JSON bodies; editor and file-watch commands are desktop-only (501 on web) - frontend transport priority: Tauri IPC -> web API (health probe) -> demo mocks; desktop-only UI hidden in web mode - 8 integration tests for the HTTP surface Deliberate v1 cuts (see specs/web-server-mode.md): no auth (loopback bind, tunnel/reverse-proxy for remote), single shared AppState. --- Cargo.lock | 35 ++ crates/diffcore-tauri/Cargo.toml | 32 +- crates/diffcore-tauri/build.rs | 6 +- crates/diffcore-tauri/src/activity_stream.rs | 15 +- crates/diffcore-tauri/src/bin/web.rs | 79 ++++ crates/diffcore-tauri/src/commands.rs | 141 +++--- crates/diffcore-tauri/src/lib.rs | 7 + crates/diffcore-tauri/src/main.rs | 1 + crates/diffcore-tauri/src/runtime.rs | 23 + crates/diffcore-tauri/src/state_shim.rs | 14 + crates/diffcore-tauri/src/web_server.rs | 410 ++++++++++++++++++ .../tests/comment_integration.rs | 13 +- crates/diffcore-tauri/tests/web_server.rs | 172 ++++++++ crates/diffcore-tauri/ui/src/App.tsx | 61 ++- crates/diffcore-tauri/ui/src/backend.ts | 45 ++ crates/diffcore-tauri/ui/src/main.tsx | 13 +- specs/readme.md | 1 + specs/web-server-mode.md | 87 ++++ 18 files changed, 1028 insertions(+), 127 deletions(-) create mode 100644 crates/diffcore-tauri/src/bin/web.rs create mode 100644 crates/diffcore-tauri/src/runtime.rs create mode 100644 crates/diffcore-tauri/src/state_shim.rs create mode 100644 crates/diffcore-tauri/src/web_server.rs create mode 100644 crates/diffcore-tauri/tests/web_server.rs create mode 100644 crates/diffcore-tauri/ui/src/backend.ts create mode 100644 specs/web-server-mode.md diff --git a/Cargo.lock b/Cargo.lock index 1e11dc8c..e71df79c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1298,9 +1298,11 @@ dependencies = [ "async-stream", "axum", "diffcore-core", + "env_logger", "futures-core", "git2", "hex", + "http-body-util", "log", "objc2", "objc2-app-kit", @@ -1317,6 +1319,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tower", "tower-http", "uuid", ] @@ -2347,6 +2350,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -3166,6 +3175,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -6456,14 +6475,24 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.11.0", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -6741,6 +6770,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/crates/diffcore-tauri/Cargo.toml b/crates/diffcore-tauri/Cargo.toml index 2fb35830..29b8f2cf 100644 --- a/crates/diffcore-tauri/Cargo.toml +++ b/crates/diffcore-tauri/Cargo.toml @@ -6,18 +6,40 @@ edition.workspace = true [build-dependencies] tauri-build = { version = "2", features = [] } +[features] +default = ["desktop"] +desktop = [ + "dep:tauri", + "dep:tauri-plugin-shell", + "dep:tauri-plugin-updater", + "dep:tauri-plugin-process", +] +web = ["tower-http/fs", "dep:env_logger"] +custom-protocol = ["desktop", "tauri/custom-protocol"] + +[[bin]] +name = "diffcore-tauri" +path = "src/main.rs" +required-features = ["desktop"] + +[[bin]] +name = "diffcore-web" +path = "src/bin/web.rs" +required-features = ["web"] + [dependencies] diffcore-core = { path = "../diffcore-core" } -tauri = { version = "2", features = [] } -tauri-plugin-shell = "2" -tauri-plugin-updater = "2" -tauri-plugin-process = "2" +tauri = { version = "2", features = [], optional = true } +tauri-plugin-shell = { version = "2", optional = true } +tauri-plugin-updater = { version = "2", optional = true } +tauri-plugin-process = { version = "2", optional = true } serde = { workspace = true } serde_json = { workspace = true } git2 = { workspace = true } tokio = { version = "1", features = ["full"] } thiserror = "2" log = { workspace = true } +env_logger = { version = "0.11", optional = true } axum = "0.7" async-stream = "0.3" tower-http = { version = "0.6", features = ["cors"] } @@ -28,6 +50,8 @@ futures-core = "0.3" [dev-dependencies] tempfile = "3" +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" serde_json = { workspace = true } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots"] } diff --git a/crates/diffcore-tauri/build.rs b/crates/diffcore-tauri/build.rs index d860e1e6..95c382ca 100644 --- a/crates/diffcore-tauri/build.rs +++ b/crates/diffcore-tauri/build.rs @@ -1,3 +1,7 @@ fn main() { - tauri_build::build() + // tauri context generation is only needed for the desktop binary; the web + // build must not require tauri.conf.json processing. + if std::env::var_os("CARGO_FEATURE_DESKTOP").is_some() { + tauri_build::build() + } } diff --git a/crates/diffcore-tauri/src/activity_stream.rs b/crates/diffcore-tauri/src/activity_stream.rs index 83879519..76353f87 100644 --- a/crates/diffcore-tauri/src/activity_stream.rs +++ b/crates/diffcore-tauri/src/activity_stream.rs @@ -212,15 +212,22 @@ impl JobHandle { } } +/// Router exposing the LLM job SSE stream; mounted standalone on desktop and +/// merged (same-origin, no CORS) into the diffcore-web server. +pub fn sse_router(manager: Arc) -> Router { + Router::new() + .route("/llm/jobs/:job_id/events", get(stream_job_events)) + .with_state(manager) +} + pub fn spawn_sse_server(manager: Arc) -> Result { let listener = StdTcpListener::bind(SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)))?; listener.set_nonblocking(true)?; let address = listener.local_addr()?; - let router = Router::new() - .route("/llm/jobs/:job_id/events", get(stream_job_events)) - .with_state(manager) - .layer(CorsLayer::new().allow_origin(Any)); + // The desktop webview loads the UI from a different origin than this + // localhost SSE server, so CORS is required here (and only here). + let router = sse_router(manager).layer(CorsLayer::new().allow_origin(Any)); thread::Builder::new() .name("diffcore-activity-sse".to_string()) diff --git a/crates/diffcore-tauri/src/bin/web.rs b/crates/diffcore-tauri/src/bin/web.rs new file mode 100644 index 00000000..aeca6cb5 --- /dev/null +++ b/crates/diffcore-tauri/src/bin/web.rs @@ -0,0 +1,79 @@ +//! diffcore-web — headless server hosting the diffcore UI in a browser. +//! +//! Binds 127.0.0.1 by default; there is no auth. For remote use, reach it +//! through an SSH tunnel or a reverse proxy that provides auth. + +use std::path::PathBuf; +use std::process::ExitCode; + +use diffcore_tauri::web_server::{serve, WebOptions}; + +const USAGE: &str = "usage: diffcore-web [--host 127.0.0.1] [--port 4400] [--ui-dir ] [--repo ] [--allowed-host ]... +Requests must carry a Host/Origin naming localhost, the bind host, or an +--allowed-host value; when binding non-loopback, pass each public hostname +or IP clients will use via --allowed-host."; + +fn parse_args() -> Result { + let mut opts = WebOptions { + host: "127.0.0.1".to_string(), + port: 4400, + ui_dir: default_ui_dir(), + default_repo: None, + allowed_hosts: Vec::new(), + }; + let mut argv = std::env::args().skip(1); + while let Some(flag) = argv.next() { + let mut value = |name: &str| argv.next().ok_or(format!("{name} needs a value")); + match flag.as_str() { + "--host" => opts.host = value("--host")?, + "--port" => { + opts.port = value("--port")? + .parse() + .map_err(|e| format!("invalid --port: {e}"))? + } + "--ui-dir" => opts.ui_dir = PathBuf::from(value("--ui-dir")?), + "--repo" => opts.default_repo = Some(value("--repo")?), + "--allowed-host" => opts.allowed_hosts.push(value("--allowed-host")?), + "--help" | "-h" => { + println!("{USAGE}"); + std::process::exit(0); + } + other => return Err(format!("unknown flag: {other}\n{USAGE}")), + } + } + if !opts.ui_dir.join("index.html").is_file() { + return Err(format!( + "UI build not found at {} — run `npm --prefix crates/diffcore-tauri/ui run build` or pass --ui-dir", + opts.ui_dir.display() + )); + } + Ok(opts) +} + +/// Built UI in the nix store layout (`../share/diffcore/ui`) or the in-repo dist. +fn default_ui_dir() -> PathBuf { + if let Some(exe_dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(PathBuf::from)) { + let store_ui = exe_dir.join("../share/diffcore/ui"); + if store_ui.join("index.html").is_file() { + return store_ui; + } + } + PathBuf::from("crates/diffcore-tauri/ui/dist") +} + +#[tokio::main] +async fn main() -> ExitCode { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + let opts = match parse_args() { + Ok(opts) => opts, + Err(msg) => { + log::error!("{msg}"); + return ExitCode::FAILURE; + } + }; + if let Err(msg) = serve(opts).await { + log::error!("{msg}"); + return ExitCode::FAILURE; + } + ExitCode::SUCCESS +} diff --git a/crates/diffcore-tauri/src/commands.rs b/crates/diffcore-tauri/src/commands.rs index 88e95fb0..721713c5 100644 --- a/crates/diffcore-tauri/src/commands.rs +++ b/crates/diffcore-tauri/src/commands.rs @@ -1,6 +1,6 @@ //! Tauri IPC commands — bridge between the React frontend and diffcore-core. //! -//! Each `#[tauri::command]` function is callable from the frontend via `invoke()`. +//! Each `#[cfg_attr(feature = "desktop", tauri::command)]` function is callable from the frontend via `invoke()`. use std::collections::HashMap; use std::path::PathBuf; @@ -8,8 +8,13 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use log::warn; -use tauri::async_runtime::JoinHandle; +use tokio::task::JoinHandle; +#[cfg(feature = "desktop")] use tauri::Emitter; +#[cfg(feature = "desktop")] +use tauri::State; +#[cfg(not(feature = "desktop"))] +use crate::state_shim::State; use crate::activity_stream::{self, ActivityEntry, JobHandle}; use diffcore_core::cache; @@ -111,7 +116,7 @@ impl AppState { let job_model = model.clone(); let job_title = title.clone(); - let handle = tauri::async_runtime::block_on(async move { + let handle = crate::runtime::background().block_on(async move { manager .create_job(job_operation, job_provider, job_model, job_title) .await @@ -159,7 +164,7 @@ impl serde::Serialize for CommandError { /// This is the primary IPC command — equivalent to `diffcore analyze` in the CLI. /// When `pr_preview` is true, uses merge-base diff (shows what the branch introduces /// relative to where it diverged from the base). -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn analyze( repo_path: String, base: Option, @@ -169,7 +174,7 @@ pub fn analyze( unstaged: bool, pr_preview: Option, include_uncommitted: Option, - state: tauri::State<'_, AppState>, + state: State<'_, AppState>, ) -> Result { let repo_path = PathBuf::from(&repo_path); let repo_path = std::fs::canonicalize(&repo_path) @@ -340,9 +345,9 @@ pub fn analyze( } /// Get the most recent analysis result without re-running. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn get_last_analysis( - state: tauri::State<'_, AppState>, + state: State<'_, AppState>, ) -> Result, CommandError> { let last = state .last_analysis @@ -352,10 +357,10 @@ pub fn get_last_analysis( } /// Generate a Mermaid diagram for a specific group by ID. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn get_mermaid( group_id: String, - state: tauri::State<'_, AppState>, + state: State<'_, AppState>, ) -> Result { let last = state .last_analysis @@ -379,7 +384,7 @@ pub fn get_mermaid( /// Returns the raw old and new content for the Monaco diff viewer. /// Uses the cached DiffResult from the last `analyze()` call when parameters match, /// avoiding redundant git diff extraction for every file navigation. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn get_file_diff( repo_path: String, file_path: String, @@ -389,7 +394,7 @@ pub fn get_file_diff( staged: bool, unstaged: bool, include_uncommitted: Option, - state: tauri::State<'_, AppState>, + state: State<'_, AppState>, ) -> Result { // Try to use cached diff from the last analyze() call let cached_file = { @@ -473,7 +478,7 @@ pub fn get_file_diff_uncached( } fn load_cached_analysis( - state: &tauri::State<'_, AppState>, + state: &State<'_, AppState>, ) -> Result { let last = state .last_analysis @@ -592,7 +597,7 @@ fn make_activity_callback( ) -> Arc { Arc::new(move |update| { let job = job.clone(); - tauri::async_runtime::spawn(async move { + crate::runtime::background().spawn(async move { job.emit(ActivityEntry { source: update.source, level: update.level, @@ -848,12 +853,12 @@ async fn run_refinement_with_activity( }) } -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn start_annotate_overview( repo_path: Option, llm_provider: Option, llm_model: Option, - state: tauri::State<'_, AppState>, + state: State<'_, AppState>, ) -> Result { let analysis = load_cached_analysis(&state)?; let (mut config, workdir) = load_config_from_path(repo_path.as_deref()); @@ -878,7 +883,7 @@ pub fn start_annotate_overview( state.create_llm_job("overview", &provider_name, &model_name, "Summarizing PR")?; let llm_config = config.llm.clone(); - tauri::async_runtime::spawn(async move { + crate::runtime::background().spawn(async move { match run_overview_with_activity(analysis, llm_config, workdir, job.clone()).await { Ok(response) => match serde_json::to_value(&response) { Ok(value) => job.complete("overview", value).await, @@ -894,7 +899,7 @@ pub fn start_annotate_overview( Ok(start) } -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn start_annotate_group( group_id: String, repo_path: String, @@ -906,7 +911,7 @@ pub fn start_annotate_group( include_uncommitted: Option, llm_provider: Option, llm_model: Option, - state: tauri::State<'_, AppState>, + state: State<'_, AppState>, ) -> Result { let analysis = load_cached_analysis(&state)?; let request = build_pass2_request( @@ -938,7 +943,7 @@ pub fn start_annotate_group( )?; let llm_config = config.llm.clone(); - tauri::async_runtime::spawn(async move { + crate::runtime::background().spawn(async move { match run_group_with_activity(request, llm_config, workdir, job.clone()).await { Ok(response) => match serde_json::to_value(&response) { Ok(value) => job.complete("group", value).await, @@ -954,12 +959,12 @@ pub fn start_annotate_group( Ok(start) } -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn start_refine_groups( repo_path: Option, llm_provider: Option, llm_model: Option, - state: tauri::State<'_, AppState>, + state: State<'_, AppState>, ) -> Result { let analysis = load_cached_analysis(&state)?; let (mut config, workdir) = load_config_from_path(repo_path.as_deref()); @@ -1014,7 +1019,7 @@ pub fn start_refine_groups( let job_id = start.job_id.clone(); let job_id_for_cleanup = job_id.clone(); let jobs_for_cleanup = Arc::clone(&state.refinement_jobs); - let handle = tauri::async_runtime::spawn(async move { + let handle = crate::runtime::background().spawn(async move { match run_refinement_with_activity(analysis, refinement_llm_config, workdir, job.clone()) .await { @@ -1047,10 +1052,10 @@ pub fn start_refine_groups( /// Aborts the spawned task, dropping any in-flight HTTP request to the LLM /// provider, and emits a failure event on the job stream so the frontend's /// `EventSource` terminates cleanly. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub async fn cancel_refine_groups( job_id: String, - state: tauri::State<'_, AppState>, + state: State<'_, AppState>, ) -> Result { let handle = { let mut map = state @@ -1074,12 +1079,12 @@ pub async fn cancel_refine_groups( /// Returns structured overview with per-group summaries, risk flags, /// and suggested review order. The result is also stored in the cached /// analysis output's `annotations` field. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub async fn annotate_overview( repo_path: Option, llm_provider: Option, llm_model: Option, - state: tauri::State<'_, AppState>, + state: State<'_, AppState>, ) -> Result { // Get the cached analysis to build the request let analysis = { @@ -1167,7 +1172,7 @@ pub async fn annotate_overview( /// Run LLM Pass 2 (deep analysis) on a specific group. /// /// Returns per-file annotations, flow narrative, and cross-cutting concerns. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub async fn annotate_group( group_id: String, repo_path: String, @@ -1179,7 +1184,7 @@ pub async fn annotate_group( include_uncommitted: Option, llm_provider: Option, llm_model: Option, - state: tauri::State<'_, AppState>, + state: State<'_, AppState>, ) -> Result { // Get the cached analysis to find the group let analysis = { @@ -1280,12 +1285,12 @@ pub async fn annotate_group( /// /// Falls back to returning the original groups if refinement produces no changes /// or validation fails. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub async fn refine_groups( repo_path: Option, llm_provider: Option, llm_model: Option, - state: tauri::State<'_, AppState>, + state: State<'_, AppState>, ) -> Result { // Get the cached analysis let analysis = { @@ -1445,10 +1450,10 @@ pub struct RefinementResult { /// /// Tries two keys: (1) diff-hash key (exact match), (2) branch-based key (same branch /// across worktrees, even with different uncommitted changes). -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn get_cached_refinement( repo_path: Option, - state: tauri::State<'_, AppState>, + state: State<'_, AppState>, ) -> Result, CommandError> { // Try diff-hash key first (exact content match) let diff_key = state.last_cache_key.lock().ok().and_then(|k| k.clone()); @@ -1478,11 +1483,11 @@ pub fn get_cached_refinement( /// Store a refinement result in the global cache (~/.diffcore/cache/refinements/). /// /// Stores under both diff-hash key and branch-based key for cross-worktree access. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn store_refinement_cache( result: RefinementResult, repo_path: Option, - state: tauri::State<'_, AppState>, + state: State<'_, AppState>, ) -> Result<(), CommandError> { let json = match serde_json::to_string(&result) { Ok(j) => j, @@ -1522,21 +1527,21 @@ pub struct AsyncLlmJobStart { /// List all local branches in the repository. /// /// Returns branches sorted with current branch first, then alphabetically. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn list_branches(repo_path: String) -> Result, CommandError> { let repo = open_repo(&repo_path)?; git::list_branches(&repo).map_err(|e| CommandError::Git(format!("{}", e))) } /// List all git worktrees for the repository. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn list_worktrees(repo_path: String) -> Result, CommandError> { let repo = open_repo(&repo_path)?; git::list_worktrees(&repo).map_err(|e| CommandError::Git(format!("{}", e))) } /// Get the current branch's tracking status (ahead/behind upstream). -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn get_branch_status(repo_path: String) -> Result { let repo = open_repo(&repo_path)?; git::get_branch_status(&repo).map_err(|e| CommandError::Git(format!("{}", e))) @@ -1545,7 +1550,7 @@ pub fn get_branch_status(repo_path: String) -> Result Result { let repo = open_repo(&repo_path)?; @@ -1569,7 +1574,7 @@ pub fn get_repo_info(repo_path: String) -> Result { /// Check whether LLM access is configured and available. /// /// This includes API-key-based providers plus subscription-backed Codex/Claude CLIs. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn check_api_key(repo_path: Option) -> Result { Ok(get_llm_settings(repo_path)?.has_api_key) } @@ -1578,7 +1583,7 @@ pub fn check_api_key(repo_path: Option) -> Result { /// /// Reads `~/.diffcore/config.toml`, merges in any repo-local `[llm]` overrides, resolves /// CLI/API availability, and returns a unified `LlmSettings` struct for the settings panel. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn get_llm_settings(repo_path: Option) -> Result { let (config, workdir) = load_config_from_path(repo_path.as_deref()); let codex_status = llm::codex_cli::detect_status(); @@ -1674,7 +1679,7 @@ pub fn get_llm_settings(repo_path: Option) -> Result Result<(), CommandError> { let mut config = DiffcoreConfig::load_global().map_err(|e| CommandError::Config(format!("{}", e)))?; @@ -1703,7 +1708,7 @@ pub fn save_llm_settings(_repo_path: String, settings: LlmSettings) -> Result<() /// /// The key is stored directly in the config file. Precedence is maintained: /// `key_cmd` > `key` (config) > env vars. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn save_api_key(_repo_path: String, api_key: String) -> Result<(), CommandError> { let mut config = DiffcoreConfig::load_global().map_err(|e| CommandError::Config(format!("{}", e)))?; @@ -1718,7 +1723,7 @@ pub fn save_api_key(_repo_path: String, api_key: String) -> Result<(), CommandEr } /// Remove the stored API key from `~/.diffcore/config.toml`. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn clear_api_key(_repo_path: String) -> Result<(), CommandError> { let mut config = DiffcoreConfig::load_global().map_err(|e| CommandError::Config(format!("{}", e)))?; @@ -1733,7 +1738,7 @@ pub fn clear_api_key(_repo_path: String) -> Result<(), CommandError> { } /// Get the current ignore paths from `.diffcore.toml`. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn get_ignore_paths(repo_path: Option) -> Result, CommandError> { let (config, _workdir) = load_config_from_path(repo_path.as_deref()); Ok(config.ignore.paths) @@ -1743,7 +1748,7 @@ pub fn get_ignore_paths(repo_path: Option) -> Result, Comman /// /// Loads the existing config (preserving other sections), updates the ignore /// paths, and writes back. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn save_ignore_paths(repo_path: String, paths: Vec) -> Result<(), CommandError> { let repo_path_buf = PathBuf::from(&repo_path); let repo_path_buf = std::fs::canonicalize(&repo_path_buf) @@ -1797,7 +1802,7 @@ fn macos_app_exists(app_name: &str) -> bool { /// /// On macOS, uses `open -a "App Name"` for GUI editors (works without PATH). /// Falls back to CLI binary for non-macOS or terminal-based editors. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn open_in_editor(editor: String, file_path: String) -> Result<(), CommandError> { let path = PathBuf::from(&file_path); if !path.exists() { @@ -1930,7 +1935,7 @@ pub fn open_in_editor(editor: String, file_path: String) -> Result<(), CommandEr /// /// On macOS, checks for .app bundles in /Applications (works without PATH). /// On other platforms, uses `which`/`where` to find CLI binaries. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn check_editors_available() -> std::collections::HashMap { let mut result = std::collections::HashMap::new(); @@ -2339,7 +2344,7 @@ fn comments_file_path(repo_path: &str) -> Result { /// /// Creates the `.diffcore/` directory if it doesn't exist. Appends to existing /// comments if the analysis hash matches, otherwise starts fresh. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn save_comment( repo_path: String, analysis_hash: String, @@ -2368,7 +2373,7 @@ pub fn save_comment( } /// Delete a comment by ID from `.diffcore/comments.json`. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn delete_comment( repo_path: String, analysis_hash: String, @@ -2387,7 +2392,7 @@ pub fn delete_comment( } /// Load all comments for a given analysis hash from `.diffcore/comments.json`. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn load_comments( repo_path: String, analysis_hash: String, @@ -2401,7 +2406,7 @@ pub fn load_comments( /// /// Includes absolute file paths, code snippets for code-level comments, /// and group context. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn export_comments(repo_path: String, analysis_hash: String) -> Result { let path = comments_file_path(&repo_path)?; let comments_file = load_comments_from_file(&path, &analysis_hash); @@ -2588,7 +2593,7 @@ fn write_cached_comments_file( } /// Save a comment to the branch-based cache. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn save_comment_cached( repo_path: String, comment: ReviewComment, @@ -2600,7 +2605,7 @@ pub fn save_comment_cached( } /// Load all comments for the current repo+branch from the cache. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn load_comments_cached(repo_path: String) -> Result, CommandError> { let key = comment_cache_key(&repo_path)?; let file = load_cached_comments_file(&key); @@ -2608,7 +2613,7 @@ pub fn load_comments_cached(repo_path: String) -> Result, Com } /// Delete a comment by ID from the branch-based cache. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn delete_comment_cached( repo_path: String, comment_id: String, @@ -2620,7 +2625,7 @@ pub fn delete_comment_cached( } /// Update a comment's text by ID in the branch-based cache. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn update_comment_cached( repo_path: String, comment_id: String, @@ -2641,10 +2646,10 @@ pub fn update_comment_cached( /// Import a groups manifest JSON and apply it to the current analysis. /// /// Returns the updated `AnalysisOutput` with groups replaced by the manifest. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn import_groups_manifest( manifest_path: String, - state: tauri::State<'_, AppState>, + state: State<'_, AppState>, ) -> Result { use diffcore_core::manifest; @@ -2669,10 +2674,10 @@ pub fn import_groups_manifest( } /// Export the current analysis groups as a manifest JSON file. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn export_groups_manifest( output_path: String, - state: tauri::State<'_, AppState>, + state: State<'_, AppState>, ) -> Result<(), CommandError> { use diffcore_core::manifest; @@ -2692,11 +2697,12 @@ pub fn export_groups_manifest( /// Start watching a manifest file for changes. Emits "manifest-changed" events /// to the frontend when the file is modified. -#[tauri::command] +#[cfg(feature = "desktop")] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn watch_manifest( manifest_path: String, app_handle: tauri::AppHandle, - state: tauri::State<'_, AppState>, + state: State<'_, AppState>, ) -> Result<(), CommandError> { // Store the path for the watcher if let Ok(mut path) = state.watched_manifest_path.lock() { @@ -2729,9 +2735,9 @@ pub fn watch_manifest( } /// Stop watching the manifest file. -#[tauri::command] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn unwatch_manifest( - state: tauri::State<'_, AppState>, + state: State<'_, AppState>, ) -> Result<(), CommandError> { if let Ok(mut path) = state.watched_manifest_path.lock() { *path = None; @@ -2742,11 +2748,12 @@ pub fn unwatch_manifest( /// Start watching a repo's HEAD for external changes (e.g. `git pull`, `checkout`, `merge` /// run outside the app). Emits a "git-head-changed" event to the frontend when the resolved /// HEAD commit OID or branch name differs from the last observed value. -#[tauri::command] +#[cfg(feature = "desktop")] +#[cfg_attr(feature = "desktop", tauri::command)] pub fn watch_git_head( repo_path: String, app_handle: tauri::AppHandle, - state: tauri::State<'_, AppState>, + state: State<'_, AppState>, ) -> Result<(), CommandError> { // Bump the generation counter so any previously running watcher thread exits. let generation = state.git_head_watch_generation.fetch_add(1, Ordering::SeqCst) + 1; @@ -2783,8 +2790,8 @@ pub fn watch_git_head( } /// Stop watching the currently watched repo's HEAD. -#[tauri::command] -pub fn unwatch_git_head(state: tauri::State<'_, AppState>) -> Result<(), CommandError> { +#[cfg_attr(feature = "desktop", tauri::command)] +pub fn unwatch_git_head(state: State<'_, AppState>) -> Result<(), CommandError> { state.git_head_watch_generation.fetch_add(1, Ordering::SeqCst); Ok(()) } diff --git a/crates/diffcore-tauri/src/lib.rs b/crates/diffcore-tauri/src/lib.rs index 9489c612..2307179c 100644 --- a/crates/diffcore-tauri/src/lib.rs +++ b/crates/diffcore-tauri/src/lib.rs @@ -1,3 +1,10 @@ +#[cfg(all(feature = "desktop", feature = "web"))] +compile_error!("features `desktop` and `web` are mutually exclusive: the web dispatcher constructs the shim State, tauri commands need the real one"); + // Re-export commands module for integration tests. pub mod activity_stream; pub mod commands; +pub mod runtime; +pub mod state_shim; +#[cfg(feature = "web")] +pub mod web_server; diff --git a/crates/diffcore-tauri/src/main.rs b/crates/diffcore-tauri/src/main.rs index 746f16c5..ab69610e 100644 --- a/crates/diffcore-tauri/src/main.rs +++ b/crates/diffcore-tauri/src/main.rs @@ -11,6 +11,7 @@ mod activity_stream; mod commands; +mod runtime; use commands::AppState; diff --git a/crates/diffcore-tauri/src/runtime.rs b/crates/diffcore-tauri/src/runtime.rs new file mode 100644 index 00000000..1619af37 --- /dev/null +++ b/crates/diffcore-tauri/src/runtime.rs @@ -0,0 +1,23 @@ +//! Process-wide background runtime for LLM jobs and SSE emission. +//! +//! Replaces `tauri::async_runtime` so command logic runs identically under the +//! desktop app and the headless web server. + +use std::sync::OnceLock; + +use tokio::runtime::{Builder, Runtime}; + +// ponytail: one global 2-thread runtime for all background jobs; per-job +// runtimes or a configurable pool if LLM concurrency ever needs tuning. +pub fn background() -> &'static Runtime { + static RT: OnceLock = OnceLock::new(); + RT.get_or_init(|| { + #[allow(clippy::expect_used)] + Builder::new_multi_thread() + .worker_threads(2) + .thread_name("diffcore-bg") + .enable_all() + .build() + .expect("failed to build background runtime") + }) +} diff --git a/crates/diffcore-tauri/src/state_shim.rs b/crates/diffcore-tauri/src/state_shim.rs new file mode 100644 index 00000000..dffb755e --- /dev/null +++ b/crates/diffcore-tauri/src/state_shim.rs @@ -0,0 +1,14 @@ +//! Drop-in replacement for `tauri::State` when the `desktop` feature is off, +//! so command signatures stay identical in web builds. + +use std::ops::Deref; + +pub struct State<'a, T: ?Sized>(pub &'a T); + +impl<'a, T: ?Sized> Deref for State<'a, T> { + type Target = T; + + fn deref(&self) -> &T { + self.0 + } +} diff --git a/crates/diffcore-tauri/src/web_server.rs b/crates/diffcore-tauri/src/web_server.rs new file mode 100644 index 00000000..c870fe17 --- /dev/null +++ b/crates/diffcore-tauri/src/web_server.rs @@ -0,0 +1,410 @@ +//! Headless web server exposing the tauri command surface over HTTP. +//! +//! `POST /api/invoke/{cmd}` mirrors tauri IPC 1:1 (JSON body with camelCase +//! keys), `GET /api/health` signals web mode to the UI, the LLM job SSE routes +//! are merged in at the same paths the desktop app uses, and the built UI is +//! served as a static SPA. + +use std::collections::HashSet; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; + +use axum::extract::{Path, Request, State as AxumState}; +use axum::http::{header, HeaderMap, StatusCode}; +use axum::middleware::{self, Next}; +use axum::response::Response; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::de::DeserializeOwned; +use serde_json::{json, Map, Value}; +use tower_http::services::{ServeDir, ServeFile}; + +use crate::activity_stream; +use crate::commands::{self, AppState, CommandError}; +use crate::state_shim::State; + +pub struct WebOptions { + pub host: String, + pub port: u16, + pub ui_dir: PathBuf, + pub default_repo: Option, + /// Extra Host-header values to accept (e.g. a reverse-proxy domain). + pub allowed_hosts: Vec, +} + +struct ServerState { + app: AppState, + default_repo: Option, +} + +/// Full router: API + SSE + static UI. Public for integration tests. +/// +/// `allowed_hosts` guards against DNS rebinding and cross-origin requests: +/// requests with a Host or Origin header naming a host outside the set are +/// rejected. Headerless clients (curl, tests) pass — the guard targets +/// browsers, which always send both. +pub fn router( + ui_dir: &std::path::Path, + default_repo: Option, + allowed_hosts: HashSet, +) -> Result { + let app = AppState::new(); + // Relative base makes `stream_url` come out as "/llm/jobs/{id}/events", + // served by this same process; also prevents the desktop localhost SSE + // server from being spawned. + { + let mut base = app + .activity_stream_base_url + .lock() + .map_err(|e| e.to_string())?; + *base = Some(String::new()); + } + + let sse = activity_stream::sse_router(Arc::clone(&app.activity_manager)); + let index = ui_dir.join("index.html"); + let state = Arc::new(ServerState { app, default_repo }); + + Ok(Router::new() + .route("/api/health", get(health)) + .route("/api/invoke/:cmd", post(invoke)) + .with_state(state) + .merge(sse) + .fallback_service(ServeDir::new(ui_dir).fallback(ServeFile::new(index))) + .layer(middleware::from_fn_with_state( + Arc::new(allowed_hosts), + host_guard, + ))) +} + +/// Strip the port and brackets from a Host/Origin host segment. +fn bare_host(value: &str) -> &str { + let value = value.strip_prefix("http://").unwrap_or(value); + let value = value.strip_prefix("https://").unwrap_or(value); + if let Some(v6) = value.strip_prefix('[') { + return v6.split(']').next().unwrap_or(v6); + } + value.rsplit_once(':').map_or(value, |(host, port)| { + if port.chars().all(|c| c.is_ascii_digit()) { + host + } else { + value + } + }) +} + +async fn host_guard( + AxumState(allowed): AxumState>>, + headers: HeaderMap, + request: Request, + next: Next, +) -> Result { + for name in [header::HOST, header::ORIGIN] { + if let Some(value) = headers.get(&name).and_then(|v| v.to_str().ok()) { + if !allowed.contains(&bare_host(value).to_ascii_lowercase()) { + return Err((StatusCode::FORBIDDEN, "host not allowed")); + } + } + } + Ok(next.run(request).await) +} + +pub async fn serve(opts: WebOptions) -> Result<(), String> { + let mut allowed: HashSet = ["localhost", "127.0.0.1", "::1"] + .into_iter() + .map(str::to_string) + .collect(); + allowed.insert(opts.host.trim_matches(['[', ']']).to_ascii_lowercase()); + allowed.extend(opts.allowed_hosts.iter().map(|h| h.to_ascii_lowercase())); + let router = router(&opts.ui_dir, opts.default_repo, allowed)?; + + let addr: SocketAddr = format!("{}:{}", opts.host, opts.port) + .parse() + .map_err(|e| format!("invalid host/port: {e}"))?; + let listener = tokio::net::TcpListener::bind(addr) + .await + .map_err(|e| format!("bind {addr}: {e}"))?; + let bound = listener.local_addr().map_err(|e| e.to_string())?; + log::info!("diffcore-web listening on http://{bound}"); + axum::serve(listener, router) + .await + .map_err(|e| e.to_string()) +} + +async fn health(AxumState(state): AxumState>) -> Json { + Json(json!({ "ok": true, "default_repo": state.default_repo })) +} + +type InvokeError = (StatusCode, String); + +fn bad_args(msg: String) -> InvokeError { + (StatusCode::BAD_REQUEST, msg) +} + +fn unsupported(msg: &str) -> InvokeError { + (StatusCode::NOT_IMPLEMENTED, msg.to_string()) +} + +impl From for InvokeError { + fn from(err: CommandError) -> Self { + (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) + } +} + +type Args = Map; + +/// Required argument, camelCase key (mirrors tauri IPC arg naming). +fn req(args: &mut Args, key: &str) -> Result { + let value = args + .remove(key) + .ok_or_else(|| bad_args(format!("missing argument: {key}")))?; + serde_json::from_value(value) + .map_err(|e| bad_args(format!("invalid argument {key}: {e}"))) +} + +/// Optional / defaultable argument: absent or null becomes `T::default()`. +fn opt(args: &mut Args, key: &str) -> Result { + match args.remove(key) { + None | Some(Value::Null) => Ok(T::default()), + Some(value) => serde_json::from_value(value) + .map_err(|e| bad_args(format!("invalid argument {key}: {e}"))), + } +} + +fn ok(value: T) -> Result { + serde_json::to_value(value) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) +} + +async fn invoke( + AxumState(state): AxumState>, + Path(cmd): Path, + // Strict Json (correct content-type, valid JSON, rejected otherwise): + // no-cors text/plain CSRF posts and malformed bodies both fail loudly. + Json(body): Json, +) -> Result, InvokeError> { + let mut args = match body { + Value::Object(map) => map, + Value::Null => Map::new(), + _ => return Err(bad_args("body must be a JSON object".into())), + }; + + // Async commands run directly on the server runtime. + match cmd.as_str() { + "cancel_refine_groups" => { + let job_id = req(&mut args, "jobId")?; + return ok(commands::cancel_refine_groups(job_id, State(&state.app)).await?) + .map(Json); + } + "annotate_overview" => { + let (a, b, c) = llm_args(&mut args)?; + return ok(commands::annotate_overview(a, b, c, State(&state.app)).await?).map(Json); + } + "refine_groups" => { + let (a, b, c) = llm_args(&mut args)?; + return ok(commands::refine_groups(a, b, c, State(&state.app)).await?).map(Json); + } + "annotate_group" => { + let group_id = req(&mut args, "groupId")?; + let d = diff_args(&mut args)?; + let (_, provider, model) = llm_args(&mut args)?; + return ok(commands::annotate_group( + group_id, + d.repo_path, + d.base, + d.head, + d.range, + d.staged, + d.unstaged, + d.include_uncommitted, + provider, + model, + State(&state.app), + ) + .await?) + .map(Json); + } + _ => {} + } + + // Sync commands (several are heavy: analysis, git, LLM cache IO) run on + // the blocking pool. + let result = tokio::task::spawn_blocking(move || dispatch_sync(&cmd, &mut args, &state.app)) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("task join error: {e}")))??; + Ok(Json(result)) +} + +struct DiffArgs { + repo_path: String, + base: Option, + head: Option, + range: Option, + staged: bool, + unstaged: bool, + include_uncommitted: Option, +} + +fn diff_args(args: &mut Args) -> Result { + Ok(DiffArgs { + repo_path: req(args, "repoPath")?, + base: opt(args, "base")?, + head: opt(args, "head")?, + range: opt(args, "range")?, + staged: opt(args, "staged")?, + unstaged: opt(args, "unstaged")?, + include_uncommitted: opt(args, "includeUncommitted")?, + }) +} + +type LlmArgs = (Option, Option, Option); + +fn llm_args(args: &mut Args) -> Result { + Ok(( + opt(args, "repoPath")?, + opt(args, "llmProvider")?, + opt(args, "llmModel")?, + )) +} + +fn dispatch_sync(cmd: &str, args: &mut Args, app: &AppState) -> Result { + let state = State(app); + match cmd { + "analyze" => { + let d = diff_args(args)?; + let pr_preview = opt(args, "prPreview")?; + ok(commands::analyze( + d.repo_path, + d.base, + d.head, + d.range, + d.staged, + d.unstaged, + pr_preview, + d.include_uncommitted, + state, + )?) + } + "get_last_analysis" => ok(commands::get_last_analysis(state)?), + "get_mermaid" => ok(commands::get_mermaid(req(args, "groupId")?, state)?), + "get_file_diff" => { + let d = diff_args(args)?; + let file_path = req(args, "filePath")?; + ok(commands::get_file_diff( + d.repo_path, + file_path, + d.base, + d.head, + d.range, + d.staged, + d.unstaged, + d.include_uncommitted, + state, + )?) + } + "start_annotate_overview" => { + let (a, b, c) = llm_args(args)?; + ok(commands::start_annotate_overview(a, b, c, state)?) + } + "start_refine_groups" => { + let (a, b, c) = llm_args(args)?; + ok(commands::start_refine_groups(a, b, c, state)?) + } + "start_annotate_group" => { + let group_id = req(args, "groupId")?; + let d = diff_args(args)?; + let (_, provider, model) = llm_args(args)?; + ok(commands::start_annotate_group( + group_id, + d.repo_path, + d.base, + d.head, + d.range, + d.staged, + d.unstaged, + d.include_uncommitted, + provider, + model, + state, + )?) + } + "get_cached_refinement" => ok(commands::get_cached_refinement( + opt(args, "repoPath")?, + state, + )?), + "store_refinement_cache" => ok(commands::store_refinement_cache( + req(args, "result")?, + opt(args, "repoPath")?, + state, + )?), + "list_branches" => ok(commands::list_branches(req(args, "repoPath")?)?), + "list_worktrees" => ok(commands::list_worktrees(req(args, "repoPath")?)?), + "get_branch_status" => ok(commands::get_branch_status(req(args, "repoPath")?)?), + "get_repo_info" => ok(commands::get_repo_info(req(args, "repoPath")?)?), + "check_api_key" => ok(commands::check_api_key(opt(args, "repoPath")?)?), + "get_llm_settings" => ok(commands::get_llm_settings(opt(args, "repoPath")?)?), + "save_llm_settings" => ok(commands::save_llm_settings( + opt(args, "repoPath")?, + req(args, "settings")?, + )?), + "save_api_key" => ok(commands::save_api_key( + opt(args, "repoPath")?, + req(args, "apiKey")?, + )?), + "clear_api_key" => ok(commands::clear_api_key(opt(args, "repoPath")?)?), + "get_ignore_paths" => ok(commands::get_ignore_paths(opt(args, "repoPath")?)?), + "save_ignore_paths" => ok(commands::save_ignore_paths( + req(args, "repoPath")?, + req(args, "paths")?, + )?), + "save_comment" => ok(commands::save_comment( + req(args, "repoPath")?, + req(args, "analysisHash")?, + req(args, "comment")?, + )?), + "delete_comment" => ok(commands::delete_comment( + req(args, "repoPath")?, + req(args, "analysisHash")?, + req(args, "commentId")?, + )?), + "load_comments" => ok(commands::load_comments( + req(args, "repoPath")?, + req(args, "analysisHash")?, + )?), + "export_comments" => ok(commands::export_comments( + req(args, "repoPath")?, + req(args, "analysisHash")?, + )?), + "save_comment_cached" => ok(commands::save_comment_cached( + req(args, "repoPath")?, + req(args, "comment")?, + )?), + "load_comments_cached" => ok(commands::load_comments_cached(req(args, "repoPath")?)?), + "delete_comment_cached" => ok(commands::delete_comment_cached( + req(args, "repoPath")?, + req(args, "commentId")?, + )?), + "update_comment_cached" => ok(commands::update_comment_cached( + req(args, "repoPath")?, + req(args, "commentId")?, + req(args, "newText")?, + )?), + "import_groups_manifest" => ok(commands::import_groups_manifest( + req(args, "manifestPath")?, + state, + )?), + "export_groups_manifest" => ok(commands::export_groups_manifest( + req(args, "outputPath")?, + state, + )?), + "unwatch_manifest" => ok(commands::unwatch_manifest(state)?), + "unwatch_git_head" => ok(commands::unwatch_git_head(state)?), + "watch_manifest" | "watch_git_head" => Err(unsupported( + "file watching is desktop-only; refresh manually in web mode", + )), + "open_in_editor" | "check_editors_available" => Err(unsupported( + "editor integration is desktop-only", + )), + _ => Err(unsupported("unknown command")), + } +} diff --git a/crates/diffcore-tauri/tests/comment_integration.rs b/crates/diffcore-tauri/tests/comment_integration.rs index 2885ed5c..721bb1cc 100644 --- a/crates/diffcore-tauri/tests/comment_integration.rs +++ b/crates/diffcore-tauri/tests/comment_integration.rs @@ -454,7 +454,7 @@ fn cached_comments_isolated_by_branch() { // Switch to branch-b let repo = Repository::discover(&repo_path).unwrap(); - let sig = Signature::now("test", "test@test.com").unwrap(); + let _sig = Signature::now("test", "test@test.com").unwrap(); let head_commit = repo.head().unwrap().peel_to_commit().unwrap(); repo.branch("branch-b", &head_commit, true).unwrap(); repo.set_head("refs/heads/branch-b").unwrap(); @@ -649,17 +649,6 @@ fn cached_all_comment_types_roundtrip() { }); } -// ══════════════════════════════════════════════════════════════════════ -// Refinement cache cross-worktree tests -// ══════════════════════════════════════════════════════════════════════ - -/// Helper: set DIFFCORE_REFINEMENT_CACHE_DIR for isolated testing. -fn with_refinement_cache_dir(dir: &std::path::Path, f: F) { - std::env::set_var("DIFFCORE_REFINEMENT_CACHE_DIR", dir.as_os_str()); - f(); - std::env::remove_var("DIFFCORE_REFINEMENT_CACHE_DIR"); -} - /// Simulated refinement JSON (lightweight, just needs to be valid for cache roundtrip). fn sample_refinement_json() -> &'static str { r#"{"refined_groups":[],"infrastructure_group":null,"refinement_response":{"splits":[],"merges":[],"re_ranks":[],"reclassifications":[],"reasoning":"test"},"provider":"test","model":"m1","had_changes":true}"# diff --git a/crates/diffcore-tauri/tests/web_server.rs b/crates/diffcore-tauri/tests/web_server.rs new file mode 100644 index 00000000..3bee7f7a --- /dev/null +++ b/crates/diffcore-tauri/tests/web_server.rs @@ -0,0 +1,172 @@ +#![cfg(feature = "web")] +//! Integration tests for the diffcore-web HTTP surface. + +use std::collections::HashSet; + +use axum::body::Body; +use axum::http::{header, Request, StatusCode}; +use http_body_util::BodyExt; +use serde_json::{json, Value}; +use tower::util::ServiceExt; + +fn test_router(ui_dir: &std::path::Path, default_repo: Option) -> axum::Router { + let allowed: HashSet = ["localhost".to_string(), "127.0.0.1".to_string()].into(); + diffcore_tauri::web_server::router(ui_dir, default_repo, allowed).unwrap() +} + +fn ui_dir() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("index.html"), "diffcore").unwrap(); + dir +} + +fn test_repo() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let repo = git2::Repository::init(dir.path()).unwrap(); + std::fs::write(dir.path().join("a.txt"), "hello\n").unwrap(); + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new("a.txt")).unwrap(); + index.write().unwrap(); + let tree = repo.find_tree(index.write_tree().unwrap()).unwrap(); + let sig = git2::Signature::now("test", "test@localhost").unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap(); + dir +} + +async fn body_json(response: axum::response::Response) -> Value { + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&bytes).unwrap() +} + +fn invoke_request(cmd: &str, args: Value) -> Request { + Request::post(format!("/api/invoke/{cmd}")) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(args.to_string())) + .unwrap() +} + +#[tokio::test] +async fn health_reports_ok_and_default_repo() { + let ui = ui_dir(); + let router = test_router(ui.path(), Some("/srv/repo".into())); + let response = router + .oneshot(Request::get("/api/health").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = body_json(response).await; + assert_eq!(body["ok"], json!(true)); + assert_eq!(body["default_repo"], json!("/srv/repo")); +} + +#[tokio::test] +async fn invoke_get_repo_info_roundtrip() { + let ui = ui_dir(); + let repo = test_repo(); + let router = test_router(ui.path(), None); + let response = router + .oneshot(invoke_request( + "get_repo_info", + json!({ "repoPath": repo.path().to_string_lossy() }), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = body_json(response).await; + assert!(body["current_branch"].is_string(), "unexpected body: {body}"); +} + +#[tokio::test] +async fn invoke_command_error_maps_to_500() { + let ui = ui_dir(); + let router = test_router(ui.path(), None); + let response = router + .oneshot(invoke_request( + "get_repo_info", + json!({ "repoPath": "/nonexistent/nowhere" }), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); +} + +#[tokio::test] +async fn invoke_missing_arg_maps_to_400() { + let ui = ui_dir(); + let router = test_router(ui.path(), None); + let response = router + .oneshot(invoke_request("get_repo_info", json!({}))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn unknown_and_desktop_only_commands_map_to_501() { + let ui = ui_dir(); + let router = test_router(ui.path(), None); + for cmd in ["definitely_not_a_command", "watch_git_head", "open_in_editor"] { + let response = router + .clone() + .oneshot(invoke_request(cmd, json!({ "repoPath": "x" }))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED, "{cmd}"); + } +} + +#[tokio::test] +async fn spa_fallback_serves_index() { + let ui = ui_dir(); + let router = test_router(ui.path(), None); + for path in ["/", "/some/spa/route"] { + let response = router + .clone() + .oneshot(Request::get(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{path}"); + } +} + +#[tokio::test] +async fn foreign_host_or_origin_is_rejected() { + let ui = ui_dir(); + let router = test_router(ui.path(), None); + for (name, value) in [("host", "evil.example:4400"), ("origin", "http://evil.example")] { + let request = Request::get("/api/health") + .header(name, value) + .body(Body::empty()) + .unwrap(); + let response = router.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN, "{name}"); + } + // allowed host (with port) still passes + let request = Request::get("/api/health") + .header("host", "localhost:4400") + .header("origin", "http://127.0.0.1:4400") + .body(Body::empty()) + .unwrap(); + let response = router.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn non_json_body_is_rejected() { + let ui = ui_dir(); + let router = test_router(ui.path(), None); + // text/plain (the no-cors CSRF shape) must not reach the dispatcher + let request = Request::post("/api/invoke/clear_api_key") + .header(header::CONTENT_TYPE, "text/plain") + .body(Body::from("{}")) + .unwrap(); + let response = router.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + // malformed JSON errors instead of silently running with defaults + let request = Request::post("/api/invoke/clear_api_key") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from("{not json")) + .unwrap(); + let response = router.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} diff --git a/crates/diffcore-tauri/ui/src/App.tsx b/crates/diffcore-tauri/ui/src/App.tsx index f3ccdedc..5d07c47c 100644 --- a/crates/diffcore-tauri/ui/src/App.tsx +++ b/crates/diffcore-tauri/ui/src/App.tsx @@ -29,14 +29,7 @@ import ErrorBoundary from "./components/ErrorBoundary"; import { buildManifestPrompt } from "./buildManifestPrompt"; import { MOCK_ANALYSIS, MOCK_DIFFS, MOCK_PASS1, MOCK_PASS2, MOCK_REPO_INFO, MOCK_LLM_SETTINGS, MOCK_REFINEMENT } from "./mock"; -/** Detect if running inside Tauri (vs plain browser for demo/testing). */ -const IS_TAURI = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; - -/** Lazy-import Tauri invoke only when in Tauri context. */ -async function tauriInvoke(cmd: string, args?: Record): Promise { - const { invoke } = await import("@tauri-apps/api/core"); - return invoke(cmd, args); -} +import { IS_TAURI, HAS_BACKEND, DEFAULT_REPO, invoke as tauriInvoke } from "./backend"; const PROVIDER_LABELS: Record = { codex: "Codex CLI", @@ -148,7 +141,7 @@ export default function App() { const [sourceFocusRequest, setSourceFocusRequest] = useState(null); // Repo and git state - const [repoPath, setRepoPath] = useState(IS_TAURI ? "" : "/demo/repo"); + const [repoPath, setRepoPath] = useState(HAS_BACKEND ? (DEFAULT_REPO ?? "") : "/demo/repo"); const [baseRef, setBaseRef] = useState("main"); const [headRef, setHeadRef] = useState(null); const [repoInfo, setRepoInfo] = useState(null); @@ -156,7 +149,7 @@ export default function App() { const [headBranchDropdownOpen, setHeadBranchDropdownOpen] = useState(false); // LLM API key availability - const [hasApiKey, setHasApiKey] = useState(!IS_TAURI); // Demo mode always has "key" + const [hasApiKey, setHasApiKey] = useState(!HAS_BACKEND); // Demo mode always has "key" // Diff behavior const [includeUncommitted, setIncludeUncommitted] = useState(true); @@ -307,7 +300,7 @@ export default function App() { const loadLlmSettings = useCallback(async (path: string | null) => { try { let settings: LlmSettings; - if (IS_TAURI) { + if (HAS_BACKEND) { settings = await tauriInvoke("get_llm_settings", { repoPath: path, }); @@ -332,7 +325,7 @@ export default function App() { if (isApiProvider(settings.provider)) { setApiProviderDraft(settings.provider as LlmProvider); } - if (!IS_TAURI) { + if (!HAS_BACKEND) { demoLlmSettingsRef.current = settings; return; } @@ -357,7 +350,7 @@ export default function App() { /** Load ignore paths from .diffcore.toml. */ const loadIgnorePaths = useCallback(async (path: string | null) => { - if (!IS_TAURI || !path) return; + if (!HAS_BACKEND || !path) return; try { const paths = await tauriInvoke("get_ignore_paths", { repoPath: path }); setIgnorePaths(paths); @@ -371,7 +364,7 @@ export default function App() { if (!path) return; try { let info: RepoInfo; - if (IS_TAURI) { + if (HAS_BACKEND) { info = await tauriInvoke("get_repo_info", { repoPath: path }); } else { await new Promise((r) => setTimeout(r, 100)); @@ -577,7 +570,7 @@ export default function App() { setSourceFocusRequest(null); // Increment generation to mark any in-flight request as stale const generation = ++fileDiffGeneration.current; - if (IS_TAURI) { + if (HAS_BACKEND) { if (!repoPath) return; try { const diff = await tauriInvoke("get_file_diff", { @@ -805,7 +798,7 @@ export default function App() { setOpenTabs([]); try { let result: AnalysisOutput; - if (IS_TAURI) { + if (HAS_BACKEND) { result = await tauriInvoke("analyze", { repoPath, base: baseRef || "main", @@ -834,7 +827,7 @@ export default function App() { handleSelectGroup(sorted[0]); } // Check for cached refinement and auto-apply if found - if (IS_TAURI) { + if (HAS_BACKEND) { tauriInvoke("get_cached_refinement", { repoPath: repoPath || null }).then((cached) => { if (cached) { applyRefinementResult(cached, { fromCache: true }); @@ -882,7 +875,7 @@ export default function App() { setAnnotating(true); setError(null); try { - if (IS_TAURI) { + if (HAS_BACKEND) { await runStreamingJob("start_annotate_overview", { repoPath: repoPath || null, llmProvider: resolvedPrimaryProvider, @@ -918,7 +911,7 @@ export default function App() { setDeepAnalyzing(true); setError(null); try { - if (IS_TAURI) { + if (HAS_BACKEND) { await runStreamingJob("start_annotate_group", { groupId: selectedGroup.id, repoPath, @@ -1014,7 +1007,7 @@ export default function App() { } // Cache the result for future sessions (non-blocking, fire-and-forget) - if (IS_TAURI && !opts?.fromCache) { + if (HAS_BACKEND && !opts?.fromCache) { tauriInvoke("store_refinement_cache", { result, repoPath: repoPath || null }).catch(() => {}); } }, [analysis, originalGroups, handleSelectGroup, showToast]); @@ -1025,7 +1018,7 @@ export default function App() { setRefining(true); setError(null); try { - if (IS_TAURI) { + if (HAS_BACKEND) { await runStreamingJob("start_refine_groups", { repoPath: repoPath || null, llmProvider: resolvedRefinementProvider, @@ -1118,7 +1111,7 @@ export default function App() { // Auto-load demo data when not in Tauri useEffect(() => { - if (!IS_TAURI && !demoLoaded.current) { + if (!HAS_BACKEND && !demoLoaded.current) { demoLoaded.current = true; runAnalysis(); } @@ -1141,7 +1134,7 @@ export default function App() { // Test API for Playwright — only available in demo/browser mode useEffect(() => { - if (IS_TAURI) return; + if (HAS_BACKEND) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).__TEST_API__ = { setRepoInfo: (data: RepoInfo | null) => setRepoInfo(data), @@ -1298,7 +1291,7 @@ export default function App() { }; try { await saveLlmSettings(updated); - if (IS_TAURI) { + if (HAS_BACKEND) { await tauriInvoke("save_api_key", { repoPath: repoPath || "", apiKey: key }); } else { demoLlmSettingsRef.current = updated; @@ -1315,7 +1308,7 @@ export default function App() { /** Clear the stored API key from the shared diffcore config and refresh settings. */ const handleClearApiKey = useCallback(async () => { try { - if (IS_TAURI) { + if (HAS_BACKEND) { await tauriInvoke("clear_api_key", { repoPath: repoPath || "" }); } else if (llmSettings) { const updated: LlmSettings = { @@ -1347,7 +1340,7 @@ export default function App() { const updated = [...ignorePaths, pattern]; setIgnorePaths(updated); setIgnorePathInput(""); - if (IS_TAURI) { + if (HAS_BACKEND) { try { await tauriInvoke("save_ignore_paths", { repoPath, paths: updated }); } catch { @@ -1361,7 +1354,7 @@ export default function App() { if (!repoPath) return; const updated = ignorePaths.filter((p) => p !== pattern); setIgnorePaths(updated); - if (IS_TAURI) { + if (HAS_BACKEND) { try { await tauriInvoke("save_ignore_paths", { repoPath, paths: updated }); } catch { @@ -1625,7 +1618,7 @@ export default function App() { const loadComments = useCallback(async () => { if (!repoPath) return; try { - if (IS_TAURI) { + if (HAS_BACKEND) { // Primary: load from branch-based cache const result = await tauriInvoke("load_comments_cached", { repoPath, @@ -1666,7 +1659,7 @@ export default function App() { const saveComment = useCallback( async (comment: ReviewComment) => { setComments((prev) => [...prev, comment]); - if (IS_TAURI && repoPath) { + if (HAS_BACKEND && repoPath) { try { await tauriInvoke("save_comment_cached", { repoPath, @@ -1684,7 +1677,7 @@ export default function App() { const deleteComment = useCallback( async (commentId: string) => { setComments((prev) => prev.filter((c) => c.id !== commentId)); - if (IS_TAURI && repoPath) { + if (HAS_BACKEND && repoPath) { try { await tauriInvoke("delete_comment_cached", { repoPath, @@ -1705,7 +1698,7 @@ export default function App() { prev.map((c) => (c.id === commentId ? { ...c, text: newText } : c)), ); setEditingCommentId(null); - if (IS_TAURI && repoPath) { + if (HAS_BACKEND && repoPath) { try { await tauriInvoke("update_comment_cached", { repoPath, @@ -1828,7 +1821,7 @@ export default function App() { /** Import a groups manifest JSON and apply it to the current analysis. */ const importGroupsManifest = useCallback(async (manifestPath: string) => { - if (!IS_TAURI) return; + if (!HAS_BACKEND) return; try { const updated = await tauriInvoke("import_groups_manifest", { manifestPath }); setAnalysis(updated); @@ -1849,7 +1842,7 @@ export default function App() { /** Export current groups as an editable manifest JSON. */ const exportGroupsManifest = useCallback(async () => { - if (!IS_TAURI || !analysis) return; + if (!HAS_BACKEND || !analysis) return; try { // Default path: .diffcore/groups.json in repo const outputPath = repoPath @@ -3978,7 +3971,7 @@ export default function App() {
{fileDiff ? fileDiff.path : "Diff Viewer"} - {fileDiff && ( + {fileDiff && (IS_TAURI || !HAS_BACKEND) && (