diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 08cffd4..903ab8c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -761,6 +761,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "dbus", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1215,6 +1236,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1428,8 +1450,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]] @@ -1439,9 +1463,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1771,6 +1797,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -2130,6 +2157,21 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -2178,6 +2220,15 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libloading" version = "0.7.4" @@ -2261,6 +2312,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[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" @@ -3151,6 +3208,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +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.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -3197,6 +3309,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -3217,6 +3339,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -3235,6 +3367,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -3337,6 +3478,46 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.2" @@ -3392,8 +3573,10 @@ dependencies = [ [[package]] name = "rudu" -version = "0.1.6" +version = "0.1.7" dependencies = [ + "keyring", + "reqwest 0.12.28", "rusqlite", "serde", "serde_json", @@ -3480,7 +3663,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -3489,6 +3672,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -3507,7 +3691,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -3536,6 +3720,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -3611,6 +3801,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3776,6 +3979,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.18.0" @@ -4192,7 +4407,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.2", "serde", "serde_json", "serde_repr", @@ -4357,7 +4572,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest", + "reqwest 0.13.2", "rustls", "semver", "serde", @@ -4589,6 +4804,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +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 = "tokio" version = "1.51.1" @@ -5171,6 +5401,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.3" @@ -5236,6 +5476,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -6118,6 +6367,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1c394ad..53703c0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,6 +24,8 @@ tauri-plugin-decorum = { git = "https://github.com/clearlysid/tauri-plugin-decor serde = { version = "1", features = ["derive"] } serde_json = "1" rusqlite = { version = "0.31", features = ["bundled"] } +keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } [target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies] tauri-plugin-process = "2.3.1" diff --git a/src-tauri/src/cache/mod.rs b/src-tauri/src/cache/mod.rs index 7d5432c..aef01c3 100644 --- a/src-tauri/src/cache/mod.rs +++ b/src-tauri/src/cache/mod.rs @@ -4,7 +4,9 @@ use std::sync::OnceLock; use rusqlite::{params, Connection, OptionalExtension}; -use crate::models::{PullRequestCore, PullRequestSummary, RepoSummary}; +use crate::models::{ + LlmSettings, PullRequestChapters, PullRequestCore, PullRequestSummary, RepoSummary, +}; use crate::support::{bool_to_sql, now_unix_timestamp, sql_to_bool}; static CACHE_DB_PATH: OnceLock = OnceLock::new(); @@ -200,6 +202,27 @@ pub fn initialize_cache_database(path: &Path) -> Result<(), String> { PRIMARY KEY (repo_name_with_owner, pr_number, head_sha) ); + CREATE TABLE IF NOT EXISTS pr_chapters_cache ( + repo_name_with_owner TEXT NOT NULL, + pr_number INTEGER NOT NULL, + head_sha TEXT NOT NULL, + prompt_version TEXT NOT NULL, + provider TEXT NOT NULL, + model TEXT NOT NULL, + chapters_json TEXT NOT NULL, + cached_at INTEGER NOT NULL, + last_accessed_at INTEGER NOT NULL, + PRIMARY KEY (repo_name_with_owner, pr_number, head_sha, prompt_version) + ); + + CREATE TABLE IF NOT EXISTS llm_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + provider TEXT NOT NULL, + model TEXT NOT NULL, + base_url TEXT, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS tracked_pull_requests ( repo_name_with_owner TEXT NOT NULL, pr_number INTEGER NOT NULL, @@ -511,6 +534,152 @@ pub fn store_changed_files( Ok(()) } +pub fn read_cached_pull_request_chapters( + repo: &str, + number: u32, + head_sha: &str, + prompt_version: &str, +) -> Result, String> { + let conn = open_cache_connection()?; + let chapters_json = conn + .query_row( + " + SELECT chapters_json + FROM pr_chapters_cache + WHERE repo_name_with_owner = ?1 + AND pr_number = ?2 + AND head_sha = ?3 + AND prompt_version = ?4 + ", + params![repo, number, head_sha, prompt_version], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|error| format!("Failed to query cached PR chapters: {error}"))?; + + let Some(chapters_json) = chapters_json else { + return Ok(None); + }; + + conn.execute( + " + UPDATE pr_chapters_cache + SET last_accessed_at = ?5 + WHERE repo_name_with_owner = ?1 + AND pr_number = ?2 + AND head_sha = ?3 + AND prompt_version = ?4 + ", + params![repo, number, head_sha, prompt_version, now_unix_timestamp()], + ) + .map_err(|error| format!("Failed to update PR chapters cache access time: {error}"))?; + + let chapters = serde_json::from_str::(&chapters_json) + .map_err(|error| format!("Failed to parse cached PR chapters: {error}"))?; + + Ok(Some(chapters)) +} + +pub fn store_pull_request_chapters(chapters: &PullRequestChapters) -> Result<(), String> { + let conn = open_cache_connection()?; + let chapters_json = serde_json::to_string(chapters) + .map_err(|error| format!("Failed to serialize PR chapters for cache: {error}"))?; + let timestamp = now_unix_timestamp(); + + conn.execute( + " + INSERT INTO pr_chapters_cache ( + repo_name_with_owner, + pr_number, + head_sha, + prompt_version, + provider, + model, + chapters_json, + cached_at, + last_accessed_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8) + ON CONFLICT(repo_name_with_owner, pr_number, head_sha, prompt_version) + DO UPDATE SET + provider = excluded.provider, + model = excluded.model, + chapters_json = excluded.chapters_json, + cached_at = excluded.cached_at, + last_accessed_at = excluded.last_accessed_at + ", + params![ + &chapters.repo, + chapters.number, + &chapters.head_sha, + &chapters.prompt_version, + &chapters.provider, + &chapters.model, + chapters_json, + timestamp, + ], + ) + .map_err(|error| format!("Failed to persist PR chapters cache: {error}"))?; + + Ok(()) +} + +pub fn read_llm_settings() -> Result, String> { + let conn = open_cache_connection()?; + let settings = conn + .query_row( + " + SELECT provider, model, base_url + FROM llm_settings + WHERE id = 1 + ", + [], + |row| { + Ok(LlmSettings { + provider: row.get(0)?, + model: row.get(1)?, + base_url: row.get(2)?, + has_api_key: false, + }) + }, + ) + .optional() + .map_err(|error| format!("Failed to read LLM settings: {error}"))?; + + Ok(settings) +} + +pub fn write_llm_settings(settings: &LlmSettings) -> Result<(), String> { + let conn = open_cache_connection()?; + conn.execute( + " + INSERT INTO llm_settings ( + id, + provider, + model, + base_url, + updated_at + ) + VALUES (1, ?1, ?2, ?3, ?4) + ON CONFLICT(id) + DO UPDATE SET + provider = excluded.provider, + model = excluded.model, + base_url = excluded.base_url, + updated_at = excluded.updated_at + ", + params![ + &settings.provider, + &settings.model, + &settings.base_url, + now_unix_timestamp(), + ], + ) + .map_err(|error| format!("Failed to persist LLM settings: {error}"))?; + + Ok(()) +} + pub fn update_repo_access_timestamp(repo: &str) -> Result<(), String> { let conn = open_cache_connection()?; conn.execute( diff --git a/src-tauri/src/commands/chapters.rs b/src-tauri/src/commands/chapters.rs new file mode 100644 index 0000000..1055fff --- /dev/null +++ b/src-tauri/src/commands/chapters.rs @@ -0,0 +1,72 @@ +use crate::models::{LlmProviderInfo, LlmSettings, PullRequestChapters, SaveLlmSettingsInput}; +use crate::services::{chapters, llm}; + +async fn run_blocking_task(task: F) -> Result +where + T: Send + 'static, + F: FnOnce() -> Result + Send + 'static, +{ + tauri::async_runtime::spawn_blocking(task) + .await + .map_err(|error| format!("Blocking task failed: {error}"))? +} + +#[tauri::command] +pub fn list_llm_providers() -> Vec { + llm::list_provider_infos() +} + +#[tauri::command] +pub async fn get_llm_settings() -> Result { + run_blocking_task(llm::load_llm_settings).await +} + +#[tauri::command] +pub async fn save_llm_settings(settings: SaveLlmSettingsInput) -> Result { + run_blocking_task(move || llm::save_llm_settings(settings)).await +} + +#[tauri::command] +pub async fn set_llm_api_key(provider: String, api_key: String) -> Result { + run_blocking_task(move || { + llm::save_api_key(&provider, &api_key)?; + llm::load_llm_settings() + }) + .await +} + +#[tauri::command] +pub async fn delete_llm_api_key(provider: String) -> Result { + run_blocking_task(move || { + llm::delete_api_key(&provider)?; + llm::load_llm_settings() + }) + .await +} + +#[tauri::command] +pub async fn test_llm_provider() -> Result<(), String> { + run_blocking_task(|| { + let settings = llm::load_llm_settings()?; + llm::test_llm_provider(&settings) + }) + .await +} + +#[tauri::command] +pub async fn get_pull_request_chapters( + repo: String, + number: u32, + head_sha: String, +) -> Result, String> { + run_blocking_task(move || chapters::read_cached_chapters(repo, number, head_sha)).await +} + +#[tauri::command] +pub async fn regenerate_pull_request_chapters( + repo: String, + number: u32, + head_sha: String, +) -> Result { + run_blocking_task(move || chapters::generate_chapters(repo, number, head_sha)).await +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e4e67f7..2869092 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ -pub mod pull_requests; +pub mod chapters; pub mod preflight; +pub mod pull_requests; pub mod repos; pub mod review_comments; pub mod tracked_pull_requests; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6599c22..61431c9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -26,6 +26,14 @@ pub fn run() { commands::repos::validate_repo, commands::repos::list_saved_repos, commands::repos::save_repo, + commands::chapters::list_llm_providers, + commands::chapters::get_llm_settings, + commands::chapters::save_llm_settings, + commands::chapters::set_llm_api_key, + commands::chapters::delete_llm_api_key, + commands::chapters::test_llm_provider, + commands::chapters::get_pull_request_chapters, + commands::chapters::regenerate_pull_request_chapters, commands::preflight::get_gh_cli_status, commands::pull_requests::list_cached_pull_requests, commands::pull_requests::list_pull_requests, diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index f03065e..b645620 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -82,6 +82,102 @@ pub struct PrPatch { pub patch: String, } +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct LlmProviderInfo { + pub id: String, + pub name: String, + pub adapter: String, + pub default_model: String, + pub default_base_url: Option, + pub base_url_required: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct LlmSettings { + pub provider: String, + pub model: String, + pub base_url: Option, + pub has_api_key: bool, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SaveLlmSettingsInput { + pub provider: String, + pub model: String, + pub base_url: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ChapterKeyChange { + pub title: String, + pub detail: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ChapterReviewFocus { + pub title: String, + pub detail: String, + pub path: Option, + pub severity: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ChapterPrologue { + pub summary: String, + pub key_changes: Vec, + pub review_focus: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PullRequestChapterFile { + pub path: String, + pub reason: String, + pub additions: u32, + pub deletions: u32, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ChapterReviewStep { + pub title: String, + pub detail: String, + pub files: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PullRequestChapter { + pub id: String, + pub title: String, + pub summary: String, + pub files: Vec, + pub review_steps: Vec, + pub risks: Vec, + pub additions: u32, + pub deletions: u32, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PullRequestChapters { + pub repo: String, + pub number: u32, + pub head_sha: String, + pub provider: String, + pub model: String, + pub prompt_version: String, + pub generated_at: i64, + pub prologue: ChapterPrologue, + pub chapters: Vec, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ReviewComment { diff --git a/src-tauri/src/services/chapters.rs b/src-tauri/src/services/chapters.rs new file mode 100644 index 0000000..7c037a8 --- /dev/null +++ b/src-tauri/src/services/chapters.rs @@ -0,0 +1,705 @@ +use std::collections::{HashMap, HashSet}; + +use serde::Deserialize; + +use crate::cache::{read_cached_pull_request_chapters, store_pull_request_chapters}; +use crate::models::{ + ChapterKeyChange, ChapterPrologue, ChapterReviewFocus, ChapterReviewStep, LlmSettings, + PullRequestChapter, PullRequestChapterFile, PullRequestChapters, +}; +use crate::services::diff_data::{DiffDataRequest, DiffDataService, GhDiffSource, SqliteDiffCache}; +use crate::services::llm::{complete_json, load_llm_settings, parse_json_response}; +use crate::support::now_unix_timestamp; + +pub const CHAPTER_PROMPT_VERSION: &str = "chapters-v1"; + +const MAX_PATCH_CHARS: usize = 120_000; +const MAX_CHAPTERS: usize = 8; + +#[derive(Debug, Clone)] +struct FileStat { + path: String, + additions: u32, + deletions: u32, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawChapterResponse { + prologue: Option, + chapters: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum RawPrologue { + Object(RawPrologueObject), + Summary(String), +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawPrologueObject { + summary: Option, + #[serde(alias = "key_changes")] + key_changes: Option>, + #[serde(alias = "review_focus")] + review_focus: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum RawTextItem { + Object(RawTextItemObject), + Text(String), +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawTextItemObject { + title: Option, + #[serde(alias = "description")] + detail: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum RawFocusItem { + Object(RawFocusItemObject), + Text(String), +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawFocusItemObject { + title: Option, + #[serde(alias = "description")] + detail: Option, + path: Option, + severity: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawChapter { + title: Option, + summary: Option, + files: Option>, + #[serde(alias = "review_steps")] + review_steps: Option>, + risks: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum RawChapterFile { + Object(RawChapterFileObject), + Path(String), +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawChapterFileObject { + path: Option, + #[serde(alias = "detail")] + reason: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum RawReviewStep { + Object(RawReviewStepObject), + Text(String), +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawReviewStepObject { + title: Option, + #[serde(alias = "description")] + detail: Option, + files: Option>, +} + +fn normalize_path(path: &str) -> String { + path.trim() + .trim_start_matches("a/") + .trim_start_matches("b/") + .to_string() +} + +fn trim_text(value: Option) -> String { + value.unwrap_or_default().trim().to_string() +} + +fn parse_diff_path(line: &str) -> Option { + if !line.starts_with("diff --git ") { + return None; + } + + let mut parts = line.split_whitespace(); + parts.next()?; + parts.next()?; + let _left = parts.next()?; + let right = parts.next()?; + + Some(normalize_path(right)) +} + +fn build_file_stats(changed_files: &[String], patch: &str) -> HashMap { + let mut stats = HashMap::new(); + for path in changed_files { + let normalized = normalize_path(path); + stats.insert( + normalized.clone(), + FileStat { + path: normalized, + additions: 0, + deletions: 0, + }, + ); + } + + let mut current_path: Option = None; + for line in patch.lines() { + if let Some(path) = parse_diff_path(line) { + current_path = Some(path); + continue; + } + + let Some(path) = current_path.as_ref() else { + continue; + }; + let Some(stat) = stats.get_mut(path) else { + continue; + }; + + if line.starts_with('+') && !line.starts_with("+++") { + stat.additions = stat.additions.saturating_add(1); + } else if line.starts_with('-') && !line.starts_with("---") { + stat.deletions = stat.deletions.saturating_add(1); + } + } + + stats +} + +fn clipped_patch(patch: &str) -> (String, bool) { + if patch.len() <= MAX_PATCH_CHARS { + return (patch.to_string(), false); + } + + let mut end = MAX_PATCH_CHARS; + while !patch.is_char_boundary(end) { + end -= 1; + } + + (patch[..end].to_string(), true) +} + +fn system_prompt() -> &'static str { + r#"You generate pull request review chapters for experienced engineers. +Return only strict JSON. Do not use markdown fences. +Group related files into reviewable chapters that reduce noise. +Every cited file path must come from the changed file list supplied by the user. +Prefer concrete review steps over generic advice. + +Required JSON shape: +{ + "prologue": { + "summary": "One concise paragraph explaining the story of the PR.", + "keyChanges": [ + { "title": "Short change title", "detail": "Specific natural-language explanation." } + ], + "reviewFocus": [ + { "title": "Risk or review focus", "detail": "Why it matters.", "path": "optional/file/path", "severity": "low|medium|high" } + ] + }, + "chapters": [ + { + "title": "Chapter title", + "summary": "What changed and why this chapter should be reviewed together.", + "files": [ + { "path": "changed/file/path", "reason": "Why this file belongs here." } + ], + "reviewSteps": [ + { "title": "Review step", "detail": "Specific action for the reviewer.", "files": ["changed/file/path"] } + ], + "risks": [ + { "title": "Risk", "detail": "What could break.", "path": "optional/file/path", "severity": "low|medium|high" } + ] + } + ] +}"# +} + +fn user_prompt( + repo: &str, + number: u32, + head_sha: &str, + file_stats: &HashMap, + patch: &str, +) -> Result { + let mut files = file_stats.values().collect::>(); + files.sort_by(|a, b| a.path.cmp(&b.path)); + + let files_json = serde_json::to_string_pretty( + &files + .iter() + .map(|stat| { + serde_json::json!({ + "path": &stat.path, + "additions": stat.additions, + "deletions": stat.deletions, + }) + }) + .collect::>(), + ) + .map_err(|error| format!("Failed to serialize changed files for prompt: {error}"))?; + + let (patch, was_clipped) = clipped_patch(patch); + let clipping_note = if was_clipped { + "The patch was clipped for model context. Prefer chapter grouping from the changed file list when later hunks are absent." + } else { + "The full patch is included." + }; + + Ok(format!( + r#"Repository: {repo} +Pull request: #{number} +Head SHA: {head_sha} +Prompt version: {CHAPTER_PROMPT_VERSION} +Patch note: {clipping_note} + +Changed files with line stats: +{files_json} + +Unified diff: +```diff +{patch} +```"# + )) +} + +fn known_path<'a>( + path: Option, + file_stats: &'a HashMap, +) -> Option<&'a FileStat> { + let path = path?; + let path = normalize_path(&path); + file_stats.get(&path) +} + +fn known_path_string(path: String, known_paths: &HashSet) -> Option { + let path = normalize_path(&path); + if known_paths.contains(&path) { + Some(path) + } else { + None + } +} + +fn normalize_focus_items( + items: Option>, + file_stats: &HashMap, +) -> Vec { + let known_paths = file_stats.keys().cloned().collect::>(); + + items + .unwrap_or_default() + .into_iter() + .filter_map(|item| { + let (title, detail, path, severity) = match item { + RawFocusItem::Object(item) => ( + trim_text(item.title), + trim_text(item.detail), + item.path, + item.severity, + ), + RawFocusItem::Text(detail) => { + (String::new(), detail.trim().to_string(), None, None) + } + }; + if title.is_empty() && detail.is_empty() { + return None; + } + + Some(ChapterReviewFocus { + title: if title.is_empty() { + "Review focus".into() + } else { + title + }, + detail, + path: path.and_then(|path| known_path_string(path, &known_paths)), + severity: severity + .map(|severity| severity.trim().to_ascii_lowercase()) + .filter(|severity| matches!(severity.as_str(), "low" | "medium" | "high")), + }) + }) + .take(6) + .collect() +} + +fn normalize_key_changes(items: Option>) -> Vec { + items + .unwrap_or_default() + .into_iter() + .filter_map(|item| { + let (title, detail) = match item { + RawTextItem::Object(item) => (trim_text(item.title), trim_text(item.detail)), + RawTextItem::Text(detail) => (String::new(), detail.trim().to_string()), + }; + if title.is_empty() && detail.is_empty() { + return None; + } + + Some(ChapterKeyChange { + title: if title.is_empty() { + "Change".into() + } else { + title + }, + detail, + }) + }) + .take(6) + .collect() +} + +fn normalize_review_steps( + steps: Option>, + known_paths: &HashSet, +) -> Vec { + steps + .unwrap_or_default() + .into_iter() + .filter_map(|step| { + let (title, detail, files) = match step { + RawReviewStep::Object(step) => ( + trim_text(step.title), + trim_text(step.detail), + step.files.unwrap_or_default(), + ), + RawReviewStep::Text(detail) => { + (String::new(), detail.trim().to_string(), Vec::new()) + } + }; + if title.is_empty() && detail.is_empty() { + return None; + } + + let files = files + .into_iter() + .filter_map(|path| known_path_string(path, known_paths)) + .collect::>(); + + Some(ChapterReviewStep { + title: if title.is_empty() { + "Review step".into() + } else { + title + }, + detail, + files, + }) + }) + .take(6) + .collect() +} + +fn fallback_prologue(file_stats: &HashMap) -> ChapterPrologue { + let additions = file_stats.values().map(|stat| stat.additions).sum::(); + let deletions = file_stats.values().map(|stat| stat.deletions).sum::(); + + ChapterPrologue { + summary: format!( + "This pull request changes {} files with +{} / -{} lines. Generate chapters to group the diff into a review sequence.", + file_stats.len(), + additions, + deletions + ), + key_changes: vec![ChapterKeyChange { + title: "Changed files collected".into(), + detail: "Rudu loaded the PR patch and file list; no model-authored grouping was available.".into(), + }], + review_focus: Vec::new(), + } +} + +fn fallback_chapter(id: String, title: String, files: Vec) -> PullRequestChapter { + let additions = files.iter().map(|stat| stat.additions).sum::(); + let deletions = files.iter().map(|stat| stat.deletions).sum::(); + + PullRequestChapter { + id, + title, + summary: "Review these changed files together because they were not assigned to a more specific chapter.".into(), + files: files + .into_iter() + .map(|stat| PullRequestChapterFile { + path: stat.path, + reason: "Unassigned changed file.".into(), + additions: stat.additions, + deletions: stat.deletions, + }) + .collect(), + review_steps: vec![ChapterReviewStep { + title: "Scan changed files".into(), + detail: "Check the diff for intent, missing tests, and interactions with the rest of the PR.".into(), + files: Vec::new(), + }], + risks: Vec::new(), + additions, + deletions, + } +} + +fn normalize_prologue( + raw: Option, + file_stats: &HashMap, +) -> ChapterPrologue { + raw.map(|prologue| match prologue { + RawPrologue::Object(prologue) => ChapterPrologue { + summary: trim_text(prologue.summary), + key_changes: normalize_key_changes(prologue.key_changes), + review_focus: normalize_focus_items(prologue.review_focus, file_stats), + }, + RawPrologue::Summary(summary) => ChapterPrologue { + summary: summary.trim().to_string(), + key_changes: Vec::new(), + review_focus: Vec::new(), + }, + }) + .filter(|prologue| { + !prologue.summary.is_empty() + || !prologue.key_changes.is_empty() + || !prologue.review_focus.is_empty() + }) + .unwrap_or_else(|| fallback_prologue(file_stats)) +} + +fn normalize_chapters( + raw: RawChapterResponse, + file_stats: &HashMap, + repo: String, + number: u32, + head_sha: String, + settings: &LlmSettings, +) -> PullRequestChapters { + let prologue = normalize_prologue(raw.prologue, file_stats); + + let known_paths = file_stats.keys().cloned().collect::>(); + let mut assigned_paths = HashSet::new(); + let mut chapters = Vec::new(); + + for raw_chapter in raw + .chapters + .unwrap_or_default() + .into_iter() + .take(MAX_CHAPTERS) + { + let mut chapter_files = Vec::new(); + let mut chapter_file_paths = HashSet::new(); + + for raw_file in raw_chapter.files.unwrap_or_default() { + let (path, reason) = match raw_file { + RawChapterFile::Object(file) => (file.path, trim_text(file.reason)), + RawChapterFile::Path(path) => (Some(path), String::new()), + }; + + let Some(stat) = known_path(path, file_stats) else { + continue; + }; + if !chapter_file_paths.insert(stat.path.clone()) { + continue; + } + + assigned_paths.insert(stat.path.clone()); + chapter_files.push(PullRequestChapterFile { + path: stat.path.clone(), + reason, + additions: stat.additions, + deletions: stat.deletions, + }); + } + + if chapter_files.is_empty() { + continue; + } + + let additions = chapter_files.iter().map(|file| file.additions).sum::(); + let deletions = chapter_files.iter().map(|file| file.deletions).sum::(); + let title = trim_text(raw_chapter.title); + let chapter_id = format!("chapter-{}", chapters.len() + 1); + + chapters.push(PullRequestChapter { + id: chapter_id, + title: if title.is_empty() { + format!("Chapter {}", chapters.len() + 1) + } else { + title + }, + summary: trim_text(raw_chapter.summary), + files: chapter_files, + review_steps: normalize_review_steps(raw_chapter.review_steps, &known_paths), + risks: normalize_focus_items(raw_chapter.risks, file_stats), + additions, + deletions, + }); + } + + let unassigned = file_stats + .values() + .filter(|stat| !assigned_paths.contains(&stat.path)) + .cloned() + .collect::>(); + + if !unassigned.is_empty() { + chapters.push(fallback_chapter( + format!("chapter-{}", chapters.len() + 1), + if chapters.is_empty() { + "Review changed files".into() + } else { + "Other changes".into() + }, + unassigned, + )); + } + + PullRequestChapters { + repo, + number, + head_sha, + provider: settings.provider.clone(), + model: settings.model.clone(), + prompt_version: CHAPTER_PROMPT_VERSION.into(), + generated_at: now_unix_timestamp(), + prologue, + chapters, + } +} + +pub fn read_cached_chapters( + repo: String, + number: u32, + head_sha: String, +) -> Result, String> { + let req = DiffDataRequest::new(repo, number, head_sha)?; + read_cached_pull_request_chapters(&req.repo, req.number, &req.head_sha, CHAPTER_PROMPT_VERSION) +} + +pub fn generate_chapters( + repo: String, + number: u32, + head_sha: String, +) -> Result { + let req = DiffDataRequest::new(repo, number, head_sha)?; + let diff_service = DiffDataService::new(&GhDiffSource, &SqliteDiffCache); + let patch = diff_service.get_patch(&req)?; + let changed_files = diff_service.get_changed_files(&req)?; + + if changed_files.is_empty() { + return Err("This pull request has no changed files to chapter.".into()); + } + + let file_stats = build_file_stats(&changed_files, &patch.patch); + let settings = load_llm_settings()?; + if !settings.has_api_key { + return Err("Configure an LLM provider API key before summarizing with AI.".into()); + } + + let prompt = user_prompt( + &req.repo, + req.number, + &req.head_sha, + &file_stats, + &patch.patch, + )?; + let response = complete_json(&settings, system_prompt(), &prompt, 5000)?; + let value = parse_json_response(&response)?; + let raw = serde_json::from_value::(value).map_err(|error| { + format!("LLM chapter response did not match the expected shape: {error}") + })?; + + let chapters = normalize_chapters( + raw, + &file_stats, + req.repo, + req.number, + req.head_sha, + &settings, + ); + store_pull_request_chapters(&chapters)?; + + Ok(chapters) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn test_settings() -> LlmSettings { + LlmSettings { + provider: "openai".into(), + model: "test-model".into(), + base_url: None, + has_api_key: true, + } + } + + fn test_file_stats() -> HashMap { + let mut stats = HashMap::new(); + stats.insert( + "src/lib.rs".into(), + FileStat { + path: "src/lib.rs".into(), + additions: 12, + deletions: 3, + }, + ); + stats + } + + #[test] + fn normalizes_string_prologue_response() { + let raw = serde_json::from_value::(json!({ + "prologue": "This PR introduces a multi-agent orchestration skill.", + "chapters": [ + { + "title": "Review orchestration", + "summary": "Adds a workflow for reviewing large PRs.", + "files": ["src/lib.rs"], + "reviewSteps": ["Confirm generated review plans cite changed files."], + "risks": ["The workflow could group unrelated changes together."] + } + ] + })) + .expect("string prologue responses should parse"); + + let chapters = normalize_chapters( + raw, + &test_file_stats(), + "owner/repo".into(), + 42, + "abc123".into(), + &test_settings(), + ); + + assert_eq!( + chapters.prologue.summary, + "This PR introduces a multi-agent orchestration skill." + ); + assert_eq!(chapters.chapters.len(), 1); + assert_eq!(chapters.chapters[0].files[0].path, "src/lib.rs"); + assert_eq!( + chapters.chapters[0].review_steps[0].detail, + "Confirm generated review plans cite changed files." + ); + assert_eq!( + chapters.chapters[0].risks[0].detail, + "The workflow could group unrelated changes together." + ); + } +} diff --git a/src-tauri/src/services/llm.rs b/src-tauri/src/services/llm.rs new file mode 100644 index 0000000..63eda15 --- /dev/null +++ b/src-tauri/src/services/llm.rs @@ -0,0 +1,525 @@ +use keyring::{Entry, Error as KeyringError}; +use reqwest::blocking::Client; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::time::Duration; + +use crate::cache::{read_llm_settings, write_llm_settings}; +use crate::models::{LlmProviderInfo, LlmSettings, SaveLlmSettingsInput}; + +const KEYCHAIN_SERVICE: &str = "rudu.llm"; + +#[derive(Clone, Copy)] +enum LlmAdapter { + OpenAiCompatible, + Anthropic, + Google, +} + +struct ProviderSpec { + id: &'static str, + name: &'static str, + adapter: LlmAdapter, + default_model: &'static str, + default_base_url: Option<&'static str>, + base_url_required: bool, +} + +struct LlmRuntimeSettings { + provider: String, + adapter: LlmAdapter, + model: String, + base_url: Option, + api_key: String, +} + +const PROVIDERS: &[ProviderSpec] = &[ + ProviderSpec { + id: "openai", + name: "OpenAI", + adapter: LlmAdapter::OpenAiCompatible, + default_model: "gpt-4.1-mini", + default_base_url: Some("https://api.openai.com/v1"), + base_url_required: false, + }, + ProviderSpec { + id: "anthropic", + name: "Anthropic", + adapter: LlmAdapter::Anthropic, + default_model: "claude-sonnet-4-5", + default_base_url: Some("https://api.anthropic.com"), + base_url_required: false, + }, + ProviderSpec { + id: "google", + name: "Google", + adapter: LlmAdapter::Google, + default_model: "gemini-2.5-flash", + default_base_url: Some("https://generativelanguage.googleapis.com/v1beta"), + base_url_required: false, + }, + ProviderSpec { + id: "openrouter", + name: "OpenRouter", + adapter: LlmAdapter::OpenAiCompatible, + default_model: "openai/gpt-4.1-mini", + default_base_url: Some("https://openrouter.ai/api/v1"), + base_url_required: false, + }, + ProviderSpec { + id: "zai", + name: "Z.ai", + adapter: LlmAdapter::OpenAiCompatible, + default_model: "glm-4.5", + default_base_url: Some("https://open.bigmodel.cn/api/paas/v4"), + base_url_required: false, + }, + ProviderSpec { + id: "minimax", + name: "Minimax", + adapter: LlmAdapter::OpenAiCompatible, + default_model: "MiniMax-M1", + default_base_url: None, + base_url_required: true, + }, + ProviderSpec { + id: "opencode", + name: "Opencode", + adapter: LlmAdapter::OpenAiCompatible, + default_model: "opencode", + default_base_url: Some("http://localhost:4096/v1"), + base_url_required: false, + }, + ProviderSpec { + id: "openai_compatible", + name: "OpenAI-compatible", + adapter: LlmAdapter::OpenAiCompatible, + default_model: "", + default_base_url: None, + base_url_required: true, + }, +]; + +pub fn list_provider_infos() -> Vec { + PROVIDERS + .iter() + .map(|provider| LlmProviderInfo { + id: provider.id.to_string(), + name: provider.name.to_string(), + adapter: match provider.adapter { + LlmAdapter::OpenAiCompatible => "openai_compatible", + LlmAdapter::Anthropic => "anthropic", + LlmAdapter::Google => "google", + } + .to_string(), + default_model: provider.default_model.to_string(), + default_base_url: provider.default_base_url.map(str::to_string), + base_url_required: provider.base_url_required, + }) + .collect() +} + +fn provider_spec(provider_id: &str) -> Result<&'static ProviderSpec, String> { + let provider_id = provider_id.trim(); + PROVIDERS + .iter() + .find(|provider| provider.id == provider_id) + .ok_or_else(|| format!("Unsupported LLM provider: {provider_id}")) +} + +fn trim_optional(value: Option) -> Option { + value + .map(|value| value.trim().trim_end_matches('/').to_string()) + .filter(|value| !value.is_empty()) +} + +fn default_settings() -> LlmSettings { + let provider = provider_spec("openai").expect("default provider exists"); + LlmSettings { + provider: provider.id.to_string(), + model: provider.default_model.to_string(), + base_url: provider.default_base_url.map(str::to_string), + has_api_key: has_api_key(provider.id), + } +} + +fn key_entry(provider: &str) -> Result { + Entry::new(KEYCHAIN_SERVICE, provider) + .map_err(|error| format!("Failed to open secure credential store: {error}")) +} + +fn has_api_key(provider: &str) -> bool { + read_api_key(provider).is_ok() +} + +pub fn read_api_key(provider: &str) -> Result { + key_entry(provider)? + .get_password() + .map_err(|error| match error { + KeyringError::NoEntry => format!("No API key saved for {provider}."), + _ => format!("Failed to read API key for {provider}: {error}"), + }) +} + +pub fn save_api_key(provider: &str, api_key: &str) -> Result<(), String> { + let provider = provider_spec(provider)?.id; + let api_key = api_key.trim(); + if api_key.is_empty() { + return Err("API key is required.".into()); + } + + key_entry(provider)? + .set_password(api_key) + .map_err(|error| format!("Failed to save API key for {provider}: {error}")) +} + +pub fn delete_api_key(provider: &str) -> Result<(), String> { + let provider = provider_spec(provider)?.id; + match key_entry(provider)?.delete_credential() { + Ok(()) | Err(KeyringError::NoEntry) => Ok(()), + Err(error) => Err(format!("Failed to delete API key for {provider}: {error}")), + } +} + +pub fn load_llm_settings() -> Result { + let Some(mut settings) = read_llm_settings()? else { + return Ok(default_settings()); + }; + + let provider = provider_spec(&settings.provider)?; + if settings.model.trim().is_empty() { + settings.model = provider.default_model.to_string(); + } + settings.base_url = trim_optional(settings.base_url).or_else(|| { + provider + .default_base_url + .filter(|_| !provider.base_url_required) + .map(str::to_string) + }); + settings.has_api_key = has_api_key(&settings.provider); + + Ok(settings) +} + +pub fn save_llm_settings(input: SaveLlmSettingsInput) -> Result { + let provider = provider_spec(&input.provider)?; + let model = input.model.trim(); + let model = if model.is_empty() { + provider.default_model + } else { + model + }; + + if model.is_empty() { + return Err("Model is required for this provider.".into()); + } + + let base_url = trim_optional(input.base_url).or_else(|| { + provider + .default_base_url + .filter(|_| !provider.base_url_required) + .map(str::to_string) + }); + + if provider.base_url_required && base_url.is_none() { + return Err("Base URL is required for this provider.".into()); + } + + let settings = LlmSettings { + provider: provider.id.to_string(), + model: model.to_string(), + base_url, + has_api_key: has_api_key(provider.id), + }; + write_llm_settings(&settings)?; + + Ok(settings) +} + +fn runtime_settings(settings: &LlmSettings) -> Result { + let provider = provider_spec(&settings.provider)?; + let model = settings.model.trim(); + if model.is_empty() { + return Err("Model is required.".into()); + } + + let base_url = trim_optional(settings.base_url.clone()).or_else(|| { + provider + .default_base_url + .filter(|_| !provider.base_url_required) + .map(str::to_string) + }); + + if provider.base_url_required && base_url.is_none() { + return Err("Base URL is required for this provider.".into()); + } + + Ok(LlmRuntimeSettings { + provider: provider.id.to_string(), + adapter: provider.adapter, + model: model.to_string(), + base_url, + api_key: read_api_key(provider.id)?, + }) +} + +pub fn test_llm_provider(settings: &LlmSettings) -> Result<(), String> { + let response = complete_json( + settings, + "Return only strict JSON.", + "Return exactly this JSON object: {\"ok\":true}", + 200, + )?; + let value = parse_json_response(&response)?; + + if value.get("ok").and_then(Value::as_bool) == Some(true) { + return Ok(()); + } + + Err("Provider responded, but did not return the expected JSON.".into()) +} + +pub fn complete_json( + settings: &LlmSettings, + system_prompt: &str, + user_prompt: &str, + max_tokens: u32, +) -> Result { + let runtime = runtime_settings(settings)?; + let client = Client::builder() + .timeout(Duration::from_secs(120)) + .build() + .map_err(|error| format!("Failed to create LLM HTTP client: {error}"))?; + + match runtime.adapter { + LlmAdapter::OpenAiCompatible => { + complete_openai_compatible(&client, &runtime, system_prompt, user_prompt, max_tokens) + } + LlmAdapter::Anthropic => { + complete_anthropic(&client, &runtime, system_prompt, user_prompt, max_tokens) + } + LlmAdapter::Google => { + complete_google(&client, &runtime, system_prompt, user_prompt, max_tokens) + } + } +} + +pub fn parse_json_response(text: &str) -> Result { + let trimmed = text.trim(); + let without_fence = if trimmed.starts_with("```") { + let after_first_line = trimmed + .find('\n') + .map(|index| &trimmed[index + 1..]) + .unwrap_or(trimmed); + after_first_line + .rfind("```") + .map(|index| &after_first_line[..index]) + .unwrap_or(after_first_line) + .trim() + } else { + trimmed + }; + + parse_first_json_object(without_fence) +} + +fn parse_first_json_object(text: &str) -> Result { + let trimmed = text.trim_start(); + if trimmed.starts_with('{') { + return parse_json_object_at(trimmed); + } + + let mut parse_error = None; + + for (start, _) in text.match_indices('{') { + match parse_json_object_at(&text[start..]) { + Ok(value) => return Ok(value), + Err(error) => parse_error = Some(error), + } + } + + match parse_error { + Some(error) => Err(error), + None => Err("LLM response did not contain a JSON object.".into()), + } +} + +fn parse_json_object_at(text: &str) -> Result { + let mut deserializer = serde_json::Deserializer::from_str(text); + let value = Value::deserialize(&mut deserializer) + .map_err(|error| format!("Failed to parse LLM JSON response: {error}"))?; + + if value.is_object() { + Ok(value) + } else { + Err("LLM response JSON was not an object.".into()) + } +} + +fn response_text(response: reqwest::blocking::Response) -> Result { + let status = response.status(); + let body = response + .text() + .map_err(|error| format!("Failed to read LLM response: {error}"))?; + + if status.is_success() { + Ok(body) + } else { + Err(format!("LLM provider returned {status}: {body}")) + } +} + +fn complete_openai_compatible( + client: &Client, + settings: &LlmRuntimeSettings, + system_prompt: &str, + user_prompt: &str, + max_tokens: u32, +) -> Result { + let base_url = settings + .base_url + .as_deref() + .ok_or_else(|| "Base URL is required for this provider.".to_string())?; + let url = format!("{base_url}/chat/completions"); + let body = json!({ + "model": &settings.model, + "messages": [ + { "role": "system", "content": system_prompt }, + { "role": "user", "content": user_prompt } + ], + "temperature": 0.2, + "max_tokens": max_tokens + }); + + let response = client + .post(url) + .bearer_auth(&settings.api_key) + .json(&body) + .send() + .map_err(|error| format!("Failed to call {}: {error}", settings.provider))?; + let body = response_text(response)?; + let value = serde_json::from_str::(&body) + .map_err(|error| format!("Failed to parse provider response: {error}"))?; + + value + .pointer("/choices/0/message/content") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| "Provider response did not include message content.".into()) +} + +fn complete_anthropic( + client: &Client, + settings: &LlmRuntimeSettings, + system_prompt: &str, + user_prompt: &str, + max_tokens: u32, +) -> Result { + let base_url = settings + .base_url + .as_deref() + .unwrap_or("https://api.anthropic.com"); + let url = format!("{base_url}/v1/messages"); + let body = json!({ + "model": &settings.model, + "system": system_prompt, + "messages": [ + { "role": "user", "content": user_prompt } + ], + "temperature": 0.2, + "max_tokens": max_tokens + }); + + let response = client + .post(url) + .header("x-api-key", &settings.api_key) + .header("anthropic-version", "2023-06-01") + .json(&body) + .send() + .map_err(|error| format!("Failed to call Anthropic: {error}"))?; + let body = response_text(response)?; + let value = serde_json::from_str::(&body) + .map_err(|error| format!("Failed to parse Anthropic response: {error}"))?; + + value + .pointer("/content/0/text") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| "Anthropic response did not include text content.".into()) +} + +fn complete_google( + client: &Client, + settings: &LlmRuntimeSettings, + system_prompt: &str, + user_prompt: &str, + max_tokens: u32, +) -> Result { + let base_url = settings + .base_url + .as_deref() + .unwrap_or("https://generativelanguage.googleapis.com/v1beta"); + let url = format!( + "{base_url}/models/{}:generateContent?key={}", + settings.model, settings.api_key + ); + let body = json!({ + "contents": [ + { + "role": "user", + "parts": [ + { "text": format!("{system_prompt}\n\n{user_prompt}") } + ] + } + ], + "generationConfig": { + "temperature": 0.2, + "maxOutputTokens": max_tokens, + "responseMimeType": "application/json" + } + }); + + let response = client + .post(url) + .json(&body) + .send() + .map_err(|error| format!("Failed to call Google: {error}"))?; + let body = response_text(response)?; + let value = serde_json::from_str::(&body) + .map_err(|error| format!("Failed to parse Google response: {error}"))?; + + value + .pointer("/candidates/0/content/parts/0/text") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| "Google response did not include text content.".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_json_with_trailing_text() { + let value = + parse_json_response("{\"ok\":true}\nDone.").expect("valid leading JSON should parse"); + + assert_eq!(value.get("ok").and_then(Value::as_bool), Some(true)); + } + + #[test] + fn parses_json_inside_fenced_response_with_trailing_text() { + let value = parse_json_response("```json\n{\"ok\":true}\n```\nDone.") + .expect("fenced JSON should parse"); + + assert_eq!(value.get("ok").and_then(Value::as_bool), Some(true)); + } + + #[test] + fn parses_first_json_object_after_preamble() { + let value = parse_json_response("Here is the result:\n{\"ok\":true}\nThanks.") + .expect("embedded JSON should parse"); + + assert_eq!(value.get("ok").and_then(Value::as_bool), Some(true)); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index ab7ba81..9a7f6c8 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,2 +1,4 @@ +pub mod chapters; pub mod diff_data; +pub mod llm; pub mod pull_request_sync; diff --git a/src/App.tsx b/src/App.tsx index 28a74e5..bdde9ce 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,8 @@ import { RepoSidebar } from "./components/ui/repo-sidebar"; import { TrackPullRequestModal } from "./components/ui/track-pull-request-modal"; import { PatchViewerMain } from "./components/ui/patch-viewer-main"; import { AppToastViewport } from "./components/ui/app-toast-viewport"; +import { ResizableHandle } from "./components/ui/resizable-handle"; +import { WindowDragRegion } from "./components/ui/window-drag-region"; import { useRepoPickerRepos, useSavedRepos, @@ -20,6 +22,7 @@ import { usePullRequestPicker } from "./hooks/usePullRequestPicker"; import { useRepoPrSelectionState } from "./hooks/useRepoPrSelectionState"; import { useTheme } from "./hooks/use-theme"; import { appToastManager } from "./lib/toasts"; +import { useResizablePanelGroup } from "./hooks/use-resizable-panel-group"; import { buildReviewThreadsByFile } from "./lib/review-threads"; import { githubKeys, savedReposQueryOptions } from "./queries/github"; import type { FileStatsEntry, PullRequestSummary, RepoSummary } from "./types/github"; @@ -31,6 +34,14 @@ function MainApp() { const [isSavingRepo, setIsSavingRepo] = useState(false); const [isTrackingPullRequest, setIsTrackingPullRequest] = useState(false); const refreshedReposRef = useRef>(new Set()); + const appPanelLayout = useResizablePanelGroup({ + id: "app-sidebar", + orientation: "horizontal", + controlledPanel: "first", + defaultSize: 25, + minSize: 18, + maxSize: 42, + }); const { repos = [] } = useSavedRepos(); const { @@ -53,7 +64,10 @@ function MainApp() { const { changedFiles, + chapters, + chaptersError, changedFilesError, + isChaptersLoading, isChangedFilesLoading, isPatchLoading, isReviewThreadsLoading, @@ -243,8 +257,11 @@ function MainApp() { return (
-
-
+
+
-
- + +
+ +
+ +
diff --git a/src/components/ui/changed-files-tree.tsx b/src/components/ui/changed-files-tree.tsx index 6b6d02f..f76566e 100644 --- a/src/components/ui/changed-files-tree.tsx +++ b/src/components/ui/changed-files-tree.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; import type { GitStatusEntry } from "@pierre/trees"; import { FileTree } from "@pierre/trees/react"; +import { normalizePath } from "../../lib/review-threads"; import type { FileStatsEntry } from "../../types/github"; type ChangedFilesTreeProps = { @@ -13,6 +14,10 @@ type ChangedFilesTreeProps = { selectedFilePath?: string | null; fileStats: Map | null; gitStatus: GitStatusEntry[] | undefined; + chapterContext?: { + title: string; + detail: string; + } | null; isDark: boolean; }; @@ -31,6 +36,7 @@ function ChangedFilesTree({ selectedFilePath, fileStats, gitStatus, + chapterContext, isDark, }: ChangedFilesTreeProps) { const initialExpandedItems = useMemo(() => { @@ -47,17 +53,32 @@ function ChangedFilesTree({ }, [files]); const fileSet = useMemo(() => new Set(files), [files]); + const selectedTreeFilePath = useMemo(() => { + if (!selectedFilePath) return null; + if (fileSet.has(selectedFilePath)) return selectedFilePath; + + const normalizedSelectedPath = normalizePath(selectedFilePath); + return ( + files.find((file) => normalizePath(file) === normalizedSelectedPath) ?? + null + ); + }, [fileSet, files, selectedFilePath]); const totals = useMemo(() => { if (!fileStats) return null; + const visibleFileSet = new Set(files.map((file) => normalizePath(file))); let additions = 0; let deletions = 0; - for (const entry of fileStats.values()) { + for (const [path, entry] of fileStats) { + if (!visibleFileSet.has(normalizePath(path))) { + continue; + } + additions += entry.additions; deletions += entry.deletions; } return { additions, deletions }; - }, [fileStats]); + }, [fileStats, files]); const onSelectFileRef = useRef(onSelectFile); const selectedFilePathRef = useRef(selectedFilePath); @@ -118,10 +139,17 @@ function ChangedFilesTree({ } >
-

- Changed files{" "} - {files.length} -

+
+

+ {chapterContext?.title ?? "Changed files"}{" "} + {files.length} +

+ {chapterContext?.detail ? ( +

+ {chapterContext.detail} +

+ ) : null} +
{totals ? ( @@ -129,7 +157,7 @@ function ChangedFilesTree({ +{formatCount(totals.additions)} - −{formatCount(totals.deletions)} + -{formatCount(totals.deletions)} ) : null} @@ -166,11 +194,7 @@ function ChangedFilesTree({ className="h-full min-h-[220px]" files={files} initialExpandedItems={initialExpandedItems} - selectedItems={ - selectedFilePath && fileSet.has(selectedFilePath) - ? [selectedFilePath] - : [] - } + selectedItems={selectedTreeFilePath ? [selectedTreeFilePath] : []} onSelectedItemsChange={handleSelectionChange} style={fileTreeStyle} options={treeOptions} diff --git a/src/components/ui/chapter-overview.tsx b/src/components/ui/chapter-overview.tsx new file mode 100644 index 0000000..f0059a3 --- /dev/null +++ b/src/components/ui/chapter-overview.tsx @@ -0,0 +1,1024 @@ +import { + ArrowLeftIcon, + ArrowPathIcon, + CheckIcon, + ChevronDownIcon, + Cog6ToothIcon, + SparklesIcon, +} from "@heroicons/react/20/solid"; +import { useState } from "react"; +import { + getFileReviewThreadsForPath, + normalizePath, + type FileReviewThreads, +} from "../../lib/review-threads"; +import type { + ChapterReviewFocus, + ChapterReviewStep, + LlmSettings, + PullRequestChapter, + PullRequestChapterFile, + PullRequestChapters, +} from "../../types/github"; + +type ChapterOverviewProps = { + chapters: PullRequestChapters | null; + isLoading: boolean; + error: string; + settings: LlmSettings | null; + settingsError: string; + isGenerating: boolean; + generationError: string; + selectedChapterId: string | null; + selectedReviewStepIndex: number | null; + completedChapterIds: Set; + reviewThreadsByFile: Map; + onGenerate: () => void; + onSelectChapter: (chapterId: string | null) => void; + onSelectReviewFocus: (focus: ChapterReviewFocus) => void; + onSelectReviewStep: (stepIndex: number | null) => void; + onToggleChapterComplete: (chapterId: string) => void; + onOpenSettings: () => void; +}; + +type RelatedFile = PullRequestChapterFile & { + isMatchedChapterFile: boolean; +}; + +function cx(...classes: Array) { + return classes.filter(Boolean).join(" "); +} + +function formatSignedCount(value: number) { + if (value >= 1000) return `${(value / 1000).toFixed(1).replace(/\.0$/, "")}k`; + return String(value); +} + +function formatFileCount(count: number) { + return `${count} ${count === 1 ? "file" : "files"}`; +} + +function formatRelativeGeneratedAt(generatedAt: number) { + const generatedAtMs = generatedAt * 1000; + const elapsedSeconds = Math.max( + 0, + Math.floor((Date.now() - generatedAtMs) / 1000), + ); + + if (elapsedSeconds < 60) return `${elapsedSeconds}s ago`; + + const elapsedMinutes = Math.floor(elapsedSeconds / 60); + if (elapsedMinutes < 60) return `${elapsedMinutes} min ago`; + + const elapsedHours = Math.floor(elapsedMinutes / 60); + if (elapsedHours < 24) return `${elapsedHours} hr ago`; + + const elapsedDays = Math.floor(elapsedHours / 24); + return `${elapsedDays}d ago`; +} + +function getUniqueChapterFileCount(chapters: PullRequestChapters) { + const fileSet = new Set(); + for (const chapter of chapters.chapters) { + for (const file of chapter.files) { + fileSet.add(normalizePath(file.path)); + } + } + return fileSet.size; +} + +function getChapterTotals(chapters: PullRequestChapters) { + return chapters.chapters.reduce( + (totals, chapter) => ({ + additions: totals.additions + chapter.additions, + deletions: totals.deletions + chapter.deletions, + }), + { additions: 0, deletions: 0 }, + ); +} + +function getSeverityRank(severity: string | null) { + switch (severity) { + case "high": + return 3; + case "medium": + return 2; + case "low": + return 1; + default: + return 0; + } +} + +function getSeverityLabel(severity: string | null) { + if (!severity) return "Low"; + return severity.charAt(0).toUpperCase() + severity.slice(1); +} + +function getChapterSeverity(chapter: PullRequestChapter) { + let highestSeverity: string | null = null; + for (const risk of chapter.risks) { + if (getSeverityRank(risk.severity) > getSeverityRank(highestSeverity)) { + highestSeverity = risk.severity; + } + } + return highestSeverity ?? "low"; +} + +function getChapterThreadCount( + chapter: PullRequestChapter, + reviewThreadsByFile: Map, +) { + return chapter.files.reduce( + (total, file) => + total + getFileReviewThreadsForPath(reviewThreadsByFile, file.path).totalCount, + 0, + ); +} + +function getSeverityClass(severity: string | null) { + switch (severity) { + case "high": + return "text-red-600 dark:text-red-300"; + case "medium": + return "text-amber-600 dark:text-amber-300"; + default: + return "text-ink-500"; + } +} + +function getSeverityBadgeClass(severity: string | null) { + switch (severity) { + case "high": + return "border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-300"; + case "medium": + return "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-300"; + default: + return "border-ink-200 bg-surface text-ink-600"; + } +} + +function getFocusKey(item: ChapterReviewFocus, index: number) { + return `${item.title}-${item.path ?? "all"}-${index}`; +} + +function getReviewStepFileCount( + chapter: PullRequestChapter, + step: ChapterReviewStep, +) { + return step.files.length > 0 ? step.files.length : chapter.files.length; +} + +function getRelatedFiles( + chapter: PullRequestChapter, + step: ChapterReviewStep, +): RelatedFile[] { + if (step.files.length === 0) { + return chapter.files.map((file) => ({ + ...file, + isMatchedChapterFile: true, + })); + } + + const chapterFilesByPath = new Map( + chapter.files.map((file) => [normalizePath(file.path), file]), + ); + + return step.files.map((path) => { + const chapterFile = chapterFilesByPath.get(normalizePath(path)); + if (chapterFile) { + return { + ...chapterFile, + isMatchedChapterFile: true, + }; + } + + return { + path, + reason: "", + additions: 0, + deletions: 0, + isMatchedChapterFile: false, + }; + }); +} + +function getRelatedRisks( + chapter: PullRequestChapter, + step: ChapterReviewStep, +): ChapterReviewFocus[] { + if (step.files.length === 0) { + return chapter.risks; + } + + const fileSet = new Set(step.files.map((file) => normalizePath(file))); + const matchingRisks = chapter.risks.filter( + (risk) => !risk.path || fileSet.has(normalizePath(risk.path)), + ); + + return matchingRisks.length > 0 ? matchingRisks : chapter.risks; +} + +type ChapterTodoDetailProps = { + chapter: PullRequestChapter; + reviewThreadsByFile: Map; + selectedStepIndex: number; + step: ChapterReviewStep; + onBack: () => void; + onSelectReviewStep: (stepIndex: number | null) => void; +}; + +function ChapterTodoDetail({ + chapter, + reviewThreadsByFile, + selectedStepIndex, + step, + onBack, + onSelectReviewStep, +}: ChapterTodoDetailProps) { + const relatedFiles = getRelatedFiles(chapter, step); + const relatedRisks = getRelatedRisks(chapter, step); + const totalThreads = relatedFiles.reduce( + (total, file) => + total + getFileReviewThreadsForPath(reviewThreadsByFile, file.path).totalCount, + 0, + ); + + return ( +
+
+
+ +
+
+

+ To-do {selectedStepIndex + 1} +

+

+ {step.title} +

+
+
+ + +{formatSignedCount(chapter.additions)} + {" "} + + -{formatSignedCount(chapter.deletions)} + +
+
+ {step.detail ? ( +

+ {step.detail} +

+ ) : null} +
+ +
+
+

+ Review checklist +

+

+ {chapter.reviewSteps.length} items +

+
+
+ {chapter.reviewSteps.map((candidateStep, index) => { + const isSelected = index === selectedStepIndex; + return ( + + ); + })} +
+
+
+ +
+
+
+

+ Related files +

+
+ {relatedFiles.length} + {totalThreads > 0 ? {totalThreads} threads : null} +
+
+ {relatedFiles.length > 0 ? ( +
+ {relatedFiles.map((file) => ( +
+
+

+ {file.path} +

+ {file.isMatchedChapterFile ? ( + + + +{formatSignedCount(file.additions)} + {" "} + + -{formatSignedCount(file.deletions)} + + + ) : null} +
+ {file.reason ? ( +

+ {file.reason} +

+ ) : null} +
+ ))} +
+ ) : ( +
+ No files attached to this to-do. +
+ )} +
+ +
+
+

+ Risks +

+
+ {relatedRisks.length > 0 ? ( +
+ {relatedRisks.map((risk, index) => ( +
+
+

+ {risk.title} +

+ {risk.path ? ( + + {risk.path} + + ) : null} +
+ {risk.detail ? ( +

+ {risk.detail} +

+ ) : null} +
+ ))} +
+ ) : ( +
+ No risks called out for this to-do. +
+ )} +
+
+
+ ); +} + +type ChapterOverviewContentProps = { + chapters: PullRequestChapters; + selectedChapter: PullRequestChapter | undefined; + selectedChapterId: string | null; + selectedReviewStep: ChapterReviewStep | null; + activeReviewFocusKey: string | null; + completedChapterIds: Set; + reviewThreadsByFile: Map; + onSelectChapter: (chapterId: string | null) => void; + onSelectReviewFocus: (focus: ChapterReviewFocus, focusKey: string) => void; + onSelectReviewStep: (stepIndex: number | null) => void; + onToggleChapterComplete: (chapterId: string) => void; +}; + +function ChapterOverviewContent({ + chapters, + selectedChapter, + selectedChapterId, + selectedReviewStep, + activeReviewFocusKey, + completedChapterIds, + reviewThreadsByFile, + onSelectChapter, + onSelectReviewFocus, + onSelectReviewStep, + onToggleChapterComplete, +}: ChapterOverviewContentProps) { + const completedCount = chapters.chapters.filter((chapter) => + completedChapterIds.has(chapter.id), + ).length; + const uniqueFileCount = getUniqueChapterFileCount(chapters); + const totals = getChapterTotals(chapters); + const leadingRisks = chapters.prologue.reviewFocus.slice(0, 4); + const recommendedStart = chapters.chapters.slice(0, 4); + const currentReviewLabel = + selectedReviewStep?.title ?? selectedChapter?.title ?? null; + + return ( +
+
+
+
+

+ AI Review Brief +

+ {chapters.prologue.summary ? ( +

+ {chapters.prologue.summary} +

+ ) : ( +

+ AI grouped this PR into reviewable chapters. +

+ )} +
+ +
+ + {leadingRisks.length > 0 ? ( +
+ {leadingRisks.map((risk, index) => ( + + ))} +
+ ) : null} + +
+ + {currentReviewLabel + ? `Reviewing: ${currentReviewLabel}` + : "Reviewing: all files"} + + + Start:{" "} + {recommendedStart.map((chapter, index) => ( + + ))} + + + Generated from {uniqueFileCount} files -{" "} + {formatRelativeGeneratedAt(chapters.generatedAt)} + + + +{formatSignedCount(totals.additions)} / - + {formatSignedCount(totals.deletions)} + +
+
+ +
+ + Show full AI summary + + +
+

+ What changed +

+ {chapters.prologue.summary ? ( +

+ {chapters.prologue.summary} +

+ ) : null} + + {chapters.prologue.keyChanges.length > 0 ? ( +
+

+ Key changes +

+
+ {chapters.prologue.keyChanges.map((item, index) => ( +
+ +
+

+ {item.title} +

+ {item.detail ? ( +

+ {item.detail} +

+ ) : null} +
+
+ ))} +
+
+ ) : null} +
+
+ + {chapters.prologue.reviewFocus.length > 0 ? ( +
+
+

+ What to review carefully +

+

+ {chapters.prologue.reviewFocus.length} focus areas +

+
+
+ {chapters.prologue.reviewFocus.map((item, index) => { + const focusKey = getFocusKey(item, index); + const isActive = activeReviewFocusKey === focusKey; + + return ( + + ); + })} +
+
+ ) : null} + +
+
+

+ AI Review Plan - {chapters.chapters.length} chapters +

+

+ {completedCount} / {chapters.chapters.length} complete +

+
+
+ {chapters.chapters.map((chapter, index) => { + const isSelected = selectedChapterId === chapter.id; + const isComplete = completedChapterIds.has(chapter.id); + const commentCount = getChapterThreadCount( + chapter, + reviewThreadsByFile, + ); + const severity = getChapterSeverity(chapter); + + return ( +
+ + + +
+ ); + })} +
+
+ + {selectedChapter ? ( +
+
+

+ {selectedChapter.title} +

+ + + +{formatSignedCount(selectedChapter.additions)} + {" "} + + -{formatSignedCount(selectedChapter.deletions)} + + +
+ + {selectedChapter.summary ? ( +

+ {selectedChapter.summary} +

+ ) : null} + +
+
+

+ Recommended review order +

+

+ {selectedChapter.reviewSteps.length} +

+
+ + {selectedChapter.reviewSteps.length > 0 ? ( +
+ {selectedChapter.reviewSteps.map((step, index) => ( + + ))} +
+ ) : ( +
+ No review steps for this chapter. +
+ )} +
+ + {selectedChapter.risks.length > 0 ? ( +
+

+ What to review carefully +

+
+ {selectedChapter.risks.map((risk, index) => ( +
+
+

+ {risk.title} +

+ {risk.detail ? ( +

+ {risk.detail} +

+ ) : null} +
+ {risk.path ? ( + + {risk.path} + + ) : null} +
+ ))} +
+
+ ) : null} +
+ ) : null} +
+ ); +} + +function ChapterOverview({ + chapters, + isLoading, + error, + settings, + settingsError, + isGenerating, + generationError, + selectedChapterId, + selectedReviewStepIndex, + completedChapterIds, + reviewThreadsByFile, + onGenerate, + onSelectChapter, + onSelectReviewFocus, + onSelectReviewStep, + onToggleChapterComplete, + onOpenSettings, +}: ChapterOverviewProps) { + const [activeReviewFocusKey, setActiveReviewFocusKey] = useState( + null, + ); + const hasApiKey = Boolean(settings?.hasApiKey); + const canGenerate = hasApiKey && !isGenerating; + const selectedChapter = chapters?.chapters.find( + (chapter) => chapter.id === selectedChapterId, + ); + const selectedReviewStep = + selectedChapter && selectedReviewStepIndex !== null + ? selectedChapter.reviewSteps[selectedReviewStepIndex] + : null; + + function handleSelectChapter(chapterId: string | null) { + setActiveReviewFocusKey(null); + onSelectChapter(chapterId); + } + + function handleSelectReviewStep(stepIndex: number | null) { + setActiveReviewFocusKey(null); + onSelectReviewStep(stepIndex); + } + + return ( +
+
+
+ +

+ {chapters + ? `AI Review Plan - ${chapters.chapters.length} chapters` + : "Summarize with AI"} + {chapters ? ( + + {chapters.provider} / {chapters.model} + + ) : null} +

+ {selectedReviewStep ? ( + + Reviewing: {selectedReviewStep.title} + + ) : selectedChapter ? ( + + Reviewing: {selectedChapter.title} + + ) : null} +
+ + + +
+ +
+ {isLoading ? ( +
+ Loading cached AI summary... +
+ ) : null} + + {settingsError ? ( +
+ {settingsError} +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + + {generationError ? ( +
+ {generationError} +
+ ) : null} + + {!isLoading && !chapters ? ( +
+

+ {hasApiKey + ? "No AI summary yet." + : "Configure a provider to summarize with AI."} +

+

+ {hasApiKey + ? "Generate a cached review story for this PR." + : "API keys are saved in your system keychain and will not be shown again."} +

+
+ ) : null} + + {chapters && selectedChapter && selectedReviewStep ? ( + handleSelectReviewStep(null)} + onSelectReviewStep={handleSelectReviewStep} + /> + ) : null} + + {chapters && !selectedReviewStep ? ( + { + setActiveReviewFocusKey(focusKey); + onSelectReviewFocus(focus); + }} + onSelectReviewStep={handleSelectReviewStep} + onToggleChapterComplete={onToggleChapterComplete} + /> + ) : null} +
+
+ ); +} + +export { ChapterOverview }; +export type { ChapterOverviewProps }; diff --git a/src/components/ui/comment-markdown.tsx b/src/components/ui/comment-markdown.tsx index cfb12c0..b44dd2e 100644 --- a/src/components/ui/comment-markdown.tsx +++ b/src/components/ui/comment-markdown.tsx @@ -1,42 +1,21 @@ import { useEffect, useState, type ComponentProps } from "react"; import Markdown, { RuleType } from "markdown-to-jsx"; -import { codeToHtml, type BundledLanguage } from "shiki"; -import type { ThemeRegistrationResolved } from "@shikijs/types"; -import pierreDarkTheme from "@pierre/theme/pierre-dark"; -import pierreLightTheme from "@pierre/theme/pierre-light"; +import { + getHighlighterOptions, + getSharedHighlighter, + type DiffsThemeNames, + type SupportedLanguages, +} from "@pierre/diffs"; import { useDocumentDarkMode } from "../../hooks/use-document-dark-mode"; const CODE_THEME = { - dark: { id: "pierre-dark", theme: toShikiTheme(pierreDarkTheme) }, - light: { id: "pierre-light", theme: toShikiTheme(pierreLightTheme) }, -} as const; + dark: "pierre-dark", + light: "pierre-light", +} as const satisfies Record<"dark" | "light", DiffsThemeNames>; const codeHtmlCache = new Map>(); -function toShikiTheme(theme: typeof pierreDarkTheme): ThemeRegistrationResolved { - const semanticTokenColors = Object.fromEntries( - Object.entries(theme.semanticTokenColors).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", - ), - ); - - return { - ...theme, - settings: theme.tokenColors.map((token) => ({ - ...token, - settings: { ...token.settings }, - })), - tokenColors: theme.tokenColors.map((token) => ({ - ...token, - settings: { ...token.settings }, - })), - semanticTokenColors, - fg: theme.colors["editor.foreground"] ?? theme.colors.foreground ?? "#000000", - bg: theme.colors["editor.background"] ?? "#ffffff", - }; -} - -function normalizeLanguage(language: string | undefined) { +function normalizeLanguage(language: string | undefined): SupportedLanguages { if (!language) return "text"; switch (language.toLowerCase()) { @@ -54,7 +33,7 @@ function normalizeLanguage(language: string | undefined) { } } -function getThemeEntry(isDark: boolean) { +function getThemeName(isDark: boolean): DiffsThemeNames { return isDark ? CODE_THEME.dark : CODE_THEME.light; } @@ -82,15 +61,19 @@ function MarkdownCodeBlock({ useEffect(() => { let cancelled = false; const normalizedLanguage = normalizeLanguage(lang); - const themeEntry = getThemeEntry(isDark); - const cacheKey = `${themeEntry.id}:${normalizedLanguage}:${text}`; + const themeName = getThemeName(isDark); + const cacheKey = `${themeName}:${normalizedLanguage}:${text}`; let promise = codeHtmlCache.get(cacheKey); if (!promise) { - promise = codeToHtml(text, { - lang: normalizedLanguage as BundledLanguage, - theme: themeEntry.theme, - }); + promise = getSharedHighlighter( + getHighlighterOptions(normalizedLanguage, { theme: themeName }), + ).then((highlighter) => + highlighter.codeToHtml(text, { + lang: normalizedLanguage, + theme: themeName, + }), + ); codeHtmlCache.set(cacheKey, promise); } diff --git a/src/components/ui/llm-settings-modal.tsx b/src/components/ui/llm-settings-modal.tsx new file mode 100644 index 0000000..3e06ab9 --- /dev/null +++ b/src/components/ui/llm-settings-modal.tsx @@ -0,0 +1,321 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "./alert-dialog"; +import { + deleteLlmApiKey, + llmKeys, + llmProvidersQueryOptions, + llmSettingsQueryOptions, + saveLlmSettings, + setLlmApiKey, + testLlmProvider, +} from "../../queries/llm"; + +type LlmSettingsModalProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +function normalizeBaseUrl(value: string) { + const trimmed = value.trim().replace(/\/+$/, ""); + return trimmed.length > 0 ? trimmed : null; +} + +function formatDuration(ms: number) { + return `${(ms / 1000).toFixed(1)}s`; +} + +function cx(...classes: Array) { + return classes.filter(Boolean).join(" "); +} + +function LlmSettingsModal({ open, onOpenChange }: LlmSettingsModalProps) { + const queryClient = useQueryClient(); + const providersQuery = useQuery(llmProvidersQueryOptions()); + const settingsQuery = useQuery(llmSettingsQueryOptions()); + const providers = providersQuery.data ?? []; + const settings = settingsQuery.data ?? null; + const [provider, setProvider] = useState("openai"); + const [model, setModel] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [feedback, setFeedback] = useState(""); + const [error, setError] = useState(""); + + const selectedProvider = useMemo( + () => providers.find((item) => item.id === provider) ?? providers[0], + [provider, providers], + ); + + useEffect(() => { + if (!open || !settings) return; + + setProvider(settings.provider); + setModel(settings.model); + setBaseUrl(settings.baseUrl ?? ""); + setApiKey(""); + setFeedback(""); + setError(""); + }, [open, settings]); + + const saveMutation = useMutation({ + mutationFn: () => + saveLlmSettings({ + provider, + model, + baseUrl: normalizeBaseUrl(baseUrl), + }), + onSuccess: (nextSettings) => { + queryClient.setQueryData(llmKeys.settings(), nextSettings); + }, + }); + + const keyMutation = useMutation({ + mutationFn: (key: string) => setLlmApiKey(provider, key), + onSuccess: (nextSettings) => { + queryClient.setQueryData(llmKeys.settings(), nextSettings); + }, + }); + + const deleteKeyMutation = useMutation({ + mutationFn: () => deleteLlmApiKey(provider), + onSuccess: (nextSettings) => { + queryClient.setQueryData(llmKeys.settings(), nextSettings); + setApiKey(""); + setFeedback("API key removed."); + }, + }); + + const testMutation = useMutation({ + mutationFn: testLlmProvider, + }); + + const isPending = + saveMutation.isPending || + keyMutation.isPending || + deleteKeyMutation.isPending || + testMutation.isPending; + + function applyProviderDefaults(nextProviderId: string) { + const nextProvider = providers.find((item) => item.id === nextProviderId); + setProvider(nextProviderId); + setModel(nextProvider?.defaultModel ?? ""); + setBaseUrl(nextProvider?.defaultBaseUrl ?? ""); + setFeedback(""); + setError(""); + } + + async function saveCurrentSettings() { + setError(""); + setFeedback(""); + const nextSettings = await saveMutation.mutateAsync(); + + if (apiKey.trim()) { + const settingsWithKey = await keyMutation.mutateAsync(apiKey); + setApiKey(""); + return settingsWithKey; + } + + return nextSettings; + } + + async function handleSave() { + try { + await saveCurrentSettings(); + setFeedback("Provider saved."); + onOpenChange(false); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } + } + + async function handleTest() { + try { + setError(""); + setFeedback(""); + await saveCurrentSettings(); + const startedAt = performance.now(); + await testMutation.mutateAsync(); + setFeedback( + `Connection successful. Model responded in ${formatDuration( + performance.now() - startedAt, + )}.`, + ); + } catch (caught) { + const detail = caught instanceof Error ? caught.message : String(caught); + setError( + `Connection failed. Check the base URL, model name, or API key. ${detail}`, + ); + } + } + + async function handleDeleteKey() { + try { + setError(""); + await deleteKeyMutation.mutateAsync(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } + } + + const hasSavedKey = + settings?.provider === provider ? settings.hasApiKey : false; + const canSave = + !isPending && + provider.trim().length > 0 && + model.trim().length > 0 && + (!selectedProvider?.baseUrlRequired || normalizeBaseUrl(baseUrl) !== null); + + return ( + + +
+ + AI Provider + + Bring your own model key for PR summaries, review chapters, and AI + diff guidance. + + +
+ +
+
+ + +
+ + + +
+ + + + {hasSavedKey ? ( +
+ + API key saved in your system keychain. It will not be shown + again. + + +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + + {feedback ? ( +
+ {feedback} +
+ ) : null} + +

+ This provider will be used to generate PR summaries, review + chapters, and AI diff guidance. +

+
+
+ + + + + Cancel + + void handleSave()} + type="button" + > + {saveMutation.isPending || keyMutation.isPending ? "Saving..." : "Save"} + + +
+
+ ); +} + +export { LlmSettingsModal }; +export type { LlmSettingsModalProps }; diff --git a/src/components/ui/patch-viewer-main.tsx b/src/components/ui/patch-viewer-main.tsx index 8db2f6c..4484e1b 100644 --- a/src/components/ui/patch-viewer-main.tsx +++ b/src/components/ui/patch-viewer-main.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState } from "react"; +import { startTransition, useEffect, useMemo, useState } from "react"; import type { CSSProperties } from "react"; +import { useQuery } from "@tanstack/react-query"; import type { DiffLineAnnotation, FileDiffMetadata, @@ -10,11 +11,18 @@ import type { import type { GitStatusEntry } from "@pierre/trees"; import { FileDiff, Virtualizer } from "@pierre/diffs/react"; import { ChangedFilesTree } from "./changed-files-tree"; +import { ChapterOverview } from "./chapter-overview"; +import { LlmSettingsModal } from "./llm-settings-modal"; +import { ResizableHandle } from "./resizable-handle"; import { ReviewCommentEditor } from "./review-comment-editor"; import { ReviewThreadCard } from "./review-thread-card"; import { OuterworldAttribution } from "./outerworld-attribution"; -import { usePullRequestReviewCommentMutations } from "../../hooks/use-github-queries"; +import { + usePullRequestChaptersMutation, + usePullRequestReviewCommentMutations, +} from "../../hooks/use-github-queries"; import { useDiffNavigator } from "../../hooks/use-diff-navigator"; +import { useResizablePanelGroup } from "../../hooks/use-resizable-panel-group"; import { getFileReviewThreadsForPath, isActiveReviewThread, @@ -24,7 +32,16 @@ import { type ReviewThread, type ReviewThreadAnnotation, } from "../../lib/review-threads"; -import type { FileStatsEntry, ReviewCommentSide } from "../../types/github"; +import { llmSettingsQueryOptions } from "../../queries/llm"; +import type { + ChapterReviewFocus, + ChapterReviewStep, + FileStatsEntry, + PullRequestChapter, + PullRequestChapterFile, + PullRequestChapters, + ReviewCommentSide, +} from "../../types/github"; const VIRTUALIZER_CONFIG: Partial = { overscrollSize: 1200, @@ -71,9 +88,16 @@ type DraftReviewCommentAnnotation = { kind: "draft"; }; +type AiHunkNoteAnnotation = { + kind: "ai-note"; + title: string; + detail: string; +}; + type PatchLineAnnotation = | ReviewThreadAnnotation - | DraftReviewCommentAnnotation; + | DraftReviewCommentAnnotation + | AiHunkNoteAnnotation; type PatchViewerMainProps = { selectedPrKey: string | null; @@ -87,6 +111,9 @@ type PatchViewerMainProps = { reviewThreads: ReviewThread[]; isReviewThreadsLoading: boolean; reviewThreadsError: string; + chapters: PullRequestChapters | null; + isChaptersLoading: boolean; + chaptersError: string; parsedPatch: { fileDiffs: FileDiffMetadata[]; parseError: string; @@ -123,6 +150,138 @@ function getSelectedLineLabel(target: DraftReviewCommentTarget | null) { return `Lines ${startLine}-${endLine}`; } +function formatCompactCount(value: number) { + if (value >= 1000) return `${(value / 1000).toFixed(1).replace(/\.0$/, "")}k`; + return String(value); +} + +function findChapterFile( + chapter: PullRequestChapter | null, + path: string, +): PullRequestChapterFile | null { + if (!chapter) return null; + + const normalizedPath = normalizePath(path); + return ( + chapter.files.find((file) => normalizePath(file.path) === normalizedPath) ?? + null + ); +} + +function getFirstChapterPath( + chapter: PullRequestChapter | null, + step: ChapterReviewStep | null = null, +) { + if (!chapter) return null; + return step?.files[0] ?? chapter.files[0]?.path ?? null; +} + +function findChapterForFocus( + chapters: PullRequestChapters | null, + focus: ChapterReviewFocus, +) { + if (!chapters) return null; + + if (focus.path) { + const normalizedFocusPath = normalizePath(focus.path); + const fileMatch = chapters.chapters.find((chapter) => + chapter.files.some( + (file) => normalizePath(file.path) === normalizedFocusPath, + ), + ); + if (fileMatch) return fileMatch; + + const riskPathMatch = chapters.chapters.find((chapter) => + chapter.risks.some( + (risk) => + risk.path !== null && normalizePath(risk.path) === normalizedFocusPath, + ), + ); + if (riskPathMatch) return riskPathMatch; + } + + const normalizedFocusTitle = focus.title.trim().toLowerCase(); + return ( + chapters.chapters.find((chapter) => + chapter.risks.some( + (risk) => risk.title.trim().toLowerCase() === normalizedFocusTitle, + ), + ) ?? + chapters.chapters.find((chapter) => + chapter.title.toLowerCase().includes(normalizedFocusTitle), + ) ?? + chapters.chapters[0] ?? + null + ); +} + +function getFirstChangedAnnotationTarget(fileDiff: FileDiffMetadata) { + for (const hunk of fileDiff.hunks) { + for (const content of hunk.hunkContent) { + if (content.type !== "change") continue; + + if (content.additions > 0) { + return { + side: "additions" as const, + lineNumber: + hunk.additionStart + + Math.max(0, content.additionLineIndex - hunk.additionLineIndex), + }; + } + + if (content.deletions > 0) { + return { + side: "deletions" as const, + lineNumber: + hunk.deletionStart + + Math.max(0, content.deletionLineIndex - hunk.deletionLineIndex), + }; + } + } + } + + return null; +} + +function getRiskForPath(chapter: PullRequestChapter, path: string) { + const normalizedPath = normalizePath(path); + return ( + chapter.risks.find( + (risk) => + risk.path !== null && normalizePath(risk.path) === normalizedPath, + ) ?? + chapter.risks[0] ?? + null + ); +} + +function buildAiHunkNoteAnnotation( + fileDiff: FileDiffMetadata, + chapter: PullRequestChapter | null, +): DiffLineAnnotation | null { + if (!chapter) return null; + + const target = getFirstChangedAnnotationTarget(fileDiff); + if (!target) return null; + + const chapterFile = findChapterFile(chapter, fileDiff.name); + const risk = getRiskForPath(chapter, fileDiff.name); + const detail = + chapterFile?.reason || + risk?.detail || + chapter.summary || + "AI grouped this hunk into the selected review chapter."; + + return { + ...target, + metadata: { + kind: "ai-note", + title: risk?.title ?? chapter.title, + detail, + }, + }; +} + type ReviewThreadsPanelProps = { threads: ReviewThread[]; isLoading: boolean; @@ -243,6 +402,9 @@ function PatchViewerMain({ reviewThreads, isReviewThreadsLoading, reviewThreadsError, + chapters, + isChaptersLoading, + chaptersError, parsedPatch, fileStats, gitStatus, @@ -250,6 +412,40 @@ function PatchViewerMain({ const [draftCommentTarget, setDraftCommentTarget] = useState(null); const [draftCommentError, setDraftCommentError] = useState(""); + const [isLlmSettingsOpen, setIsLlmSettingsOpen] = useState(false); + const [selectedChapterId, setSelectedChapterId] = useState( + null, + ); + const [selectedReviewStepIndex, setSelectedReviewStepIndex] = useState< + number | null + >(null); + const [completedChapterIds, setCompletedChapterIds] = useState>( + () => new Set(), + ); + const chapterPanelLayout = useResizablePanelGroup({ + id: "chapter-overview", + orientation: "vertical", + controlledPanel: "first", + defaultSize: 28, + minSize: 14, + maxSize: 44, + }); + const reviewSidePanelLayout = useResizablePanelGroup({ + id: "review-side-panel", + orientation: "horizontal", + controlledPanel: "second", + defaultSize: 33, + minSize: 22, + maxSize: 50, + }); + const fileCommentsPanelLayout = useResizablePanelGroup({ + id: "file-comments", + orientation: "vertical", + controlledPanel: "first", + defaultSize: 60, + minSize: 35, + maxSize: 78, + }); const hasSelection = selectedPrKey !== null; const shouldShowCommentsPanel = hasSelection && @@ -275,15 +471,180 @@ function PatchViewerMain({ } : null, ); + const chapterMutation = usePullRequestChaptersMutation( + selectedPatch + ? { + repo: selectedPatch.repo, + number: selectedPatch.number, + headSha: selectedPatch.headSha, + } + : null, + ); + const llmSettingsQuery = useQuery({ + ...llmSettingsQueryOptions(), + enabled: hasSelection, + }); + const llmSettings = llmSettingsQuery.data ?? null; + + const selectedChapter = useMemo( + () => + chapters?.chapters.find((chapter) => chapter.id === selectedChapterId) ?? + null, + [chapters, selectedChapterId], + ); + const selectedReviewStep = useMemo(() => { + if (!selectedChapter || selectedReviewStepIndex === null) { + return null; + } + + return selectedChapter.reviewSteps[selectedReviewStepIndex] ?? null; + }, [selectedChapter, selectedReviewStepIndex]); + const selectedChapterFileSet = useMemo(() => { + if (!selectedChapter) return null; + const reviewStepFiles = selectedReviewStep?.files ?? []; + const visibleFiles = + reviewStepFiles.length > 0 + ? reviewStepFiles + : selectedChapter.files.map((file) => file.path); + + return new Set(visibleFiles.map((file) => normalizePath(file))); + }, [selectedChapter, selectedReviewStep]); + const visibleChangedFiles = useMemo(() => { + if (!selectedChapterFileSet) return changedFiles; + return changedFiles.filter((path) => + selectedChapterFileSet.has(normalizePath(path)), + ); + }, [changedFiles, selectedChapterFileSet]); + const visibleFileDiffs = useMemo(() => { + if (!selectedChapterFileSet) return parsedPatch.fileDiffs; + return parsedPatch.fileDiffs.filter((fileDiff) => + selectedChapterFileSet.has(normalizePath(fileDiff.name)), + ); + }, [parsedPatch.fileDiffs, selectedChapterFileSet]); + const aiNoteFilePaths = useMemo(() => { + if (!selectedChapter) return new Set(); + + const notePaths = new Set(); + for (const fileDiff of visibleFileDiffs) { + if (!buildAiHunkNoteAnnotation(fileDiff, selectedChapter)) { + continue; + } + + notePaths.add(normalizePath(fileDiff.name)); + if (notePaths.size >= 2) break; + } + + return notePaths; + }, [selectedChapter, visibleFileDiffs]); + const selectedReviewLabel = + selectedReviewStep?.title ?? selectedChapter?.title ?? null; + const changedFilesContext = selectedChapter + ? { + title: selectedReviewStep + ? `Chapter files: ${selectedReviewStep.title}` + : `Chapter files: ${selectedChapter.title}`, + detail: + selectedReviewStep?.detail || + selectedChapter.summary || + "Showing only files matched to the selected AI chapter.", + } + : null; useEffect(() => { setDraftCommentTarget(null); setDraftCommentError(""); + setSelectedChapterId(null); + setSelectedReviewStepIndex(null); + setCompletedChapterIds(new Set()); }, [selectedPrKey]); + useEffect(() => { + if (!selectedChapterId) return; + if (chapters?.chapters.some((chapter) => chapter.id === selectedChapterId)) { + return; + } + + setSelectedChapterId(null); + setSelectedReviewStepIndex(null); + }, [chapters, selectedChapterId]); + + useEffect(() => { + if (selectedReviewStepIndex === null) return; + if ( + selectedChapter && + selectedReviewStepIndex < selectedChapter.reviewSteps.length + ) { + return; + } + + setSelectedReviewStepIndex(null); + }, [selectedChapter, selectedReviewStepIndex]); + useEffect(() => { navigator.actions.notifyDiffContentChanged(); - }, [navigator.actions, parsedPatch.fileDiffs, reviewThreadsByFile]); + }, [ + navigator.actions, + parsedPatch.fileDiffs, + reviewThreadsByFile, + selectedChapterFileSet, + ]); + + function handleSelectChapter(chapterId: string | null) { + const nextChapter = + chapters?.chapters.find((chapter) => chapter.id === chapterId) ?? null; + + startTransition(() => { + setSelectedChapterId(chapterId); + setSelectedReviewStepIndex(null); + }); + + const firstPath = getFirstChapterPath(nextChapter); + if (firstPath) { + navigator.tree.onSelectFile(firstPath); + } + } + + function handleSelectReviewStep(stepIndex: number | null) { + const nextStep = + selectedChapter && stepIndex !== null + ? selectedChapter.reviewSteps[stepIndex] ?? null + : null; + + startTransition(() => { + setSelectedReviewStepIndex(stepIndex); + }); + + const firstPath = getFirstChapterPath(selectedChapter, nextStep); + if (firstPath) { + navigator.tree.onSelectFile(firstPath); + } + } + + function handleSelectReviewFocus(focus: ChapterReviewFocus) { + const chapter = findChapterForFocus(chapters, focus); + const firstPath = focus.path ?? getFirstChapterPath(chapter); + + startTransition(() => { + setSelectedChapterId(chapter?.id ?? null); + setSelectedReviewStepIndex(null); + }); + + if (firstPath) { + navigator.tree.onSelectFile(firstPath); + } + } + + function handleToggleChapterComplete(chapterId: string) { + setCompletedChapterIds((current) => { + const next = new Set(current); + if (next.has(chapterId)) { + next.delete(chapterId); + } else { + next.add(chapterId); + } + return next; + }); + } function openLineCommentDraft(path: string, range: SelectedLineRange) { const startSide = range.side ?? range.endSide; @@ -432,6 +793,25 @@ function PatchViewerMain({ ); } + if ( + "kind" in annotation.metadata && + annotation.metadata.kind === "ai-note" + ) { + return ( +
+
+ AI note + + {annotation.metadata.title} + +
+

+ {annotation.metadata.detail} +

+
+ ); + } + const threadAnnotation = annotation.metadata as ReviewThreadAnnotation; return ( @@ -462,15 +842,63 @@ function PatchViewerMain({ } return ( -
-
-
-
- + <> +
+
+
+ void chapterMutation.mutate()} + onOpenSettings={() => setIsLlmSettingsOpen(true)} + onSelectChapter={handleSelectChapter} + onSelectReviewFocus={handleSelectReviewFocus} + onSelectReviewStep={handleSelectReviewStep} + onToggleChapterComplete={handleToggleChapterComplete} + reviewThreadsByFile={reviewThreadsByFile} + selectedChapterId={selectedChapterId} + selectedReviewStepIndex={selectedReviewStepIndex} + settings={llmSettings} + settingsError={ + llmSettingsQuery.error instanceof Error + ? llmSettingsQuery.error.message + : llmSettingsQuery.error + ? String(llmSettingsQuery.error) + : "" + } + /> +
+ +
+
+ {!selectedPrKey && !isPatchLoading ? (
Select a pull request. @@ -506,6 +934,28 @@ function PatchViewerMain({ {!isPatchLoading && !patchError && selectedPatch ? (
+ {selectedChapter ? ( +
+
+

+ Reviewing: {selectedReviewLabel} +

+

+ + +{formatCompactCount(selectedChapter.additions)} + {" "} + + -{formatCompactCount(selectedChapter.deletions)} + +

+
+

+ {visibleChangedFiles.length} matched files - AI notes are + limited to the highest-signal hunks. +

+
+ ) : null} + {parsedPatch.parseError ? (
{parsedPatch.parseError} @@ -514,9 +964,13 @@ function PatchViewerMain({
                       {selectedPatch.patch}
                     
+ ) : visibleFileDiffs.length === 0 ? ( +
+ No parsed diffs matched this AI summary. +
) : (
- {parsedPatch.fileDiffs.map((fileDiff) => { + {visibleFileDiffs.map((fileDiff) => { const fileReviewThreads = getFileReviewThreadsForPath( reviewThreadsByFile, fileDiff.name, @@ -547,17 +1001,32 @@ function PatchViewerMain({ fileDraft = draftCommentTarget; } + const aiHunkNote = + selectedChapter && + aiNoteFilePaths.has(normalizedFilePath) + ? buildAiHunkNoteAnnotation( + fileDiff, + selectedChapter, + ) + : null; + const baseLineAnnotations: DiffLineAnnotation[] = + aiHunkNote + ? [ + ...fileReviewThreads.lineAnnotations, + aiHunkNote, + ] + : fileReviewThreads.lineAnnotations; const lineAnnotations: DiffLineAnnotation[] = lineDraft ? [ - ...fileReviewThreads.lineAnnotations, + ...baseLineAnnotations, { side: toSelectionSide(lineDraft.side), lineNumber: lineDraft.line, metadata: { kind: "draft" }, }, ] - : fileReviewThreads.lineAnnotations; + : baseLineAnnotations; const selectedLines: SelectedLineRange | null = lineDraft ? { @@ -672,50 +1141,79 @@ function PatchViewerMain({ )}
) : null} - -
-
+ +
+
- -
- - {shouldShowCommentsPanel ? ( -
- +
- ) : null} + + {shouldShowCommentsPanel ? ( + <> + +
+ +
+ + ) : null} +
-
-
+ + + ); } diff --git a/src/components/ui/repo-sidebar.tsx b/src/components/ui/repo-sidebar.tsx index c9b6b73..9cdb8a0 100644 --- a/src/components/ui/repo-sidebar.tsx +++ b/src/components/ui/repo-sidebar.tsx @@ -1,8 +1,8 @@ import { MoonIcon, PlusIcon, SunIcon } from "@heroicons/react/20/solid"; -import { getCurrentWindow } from "@tauri-apps/api/window"; import { Accordion } from "./accordion"; import { AppUpdater } from "./app-updater"; import { RepoSidebarItem, type PullRequestSummary } from "./repo-sidebar-item"; +import { WindowDragRegion } from "./window-drag-region"; import type { RepoSummary } from "../../types/github"; type RepoSidebarProps = { @@ -34,23 +34,9 @@ function RepoSidebar({ onRemovePr, onRepoOpenChange, }: RepoSidebarProps) { - const appWindow = getCurrentWindow(); - return (